app
Adom Symbol
Public Made by Adomby adom
KiCad symbol creator with interactive viewer and delivery to KiCad/Fusion 360
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710
/**
* .kicad_sym Generator
*
* Creates KiCad symbol files from structured pin data.
*
* Category A (ICs): Generates rectangle body with pins on sides, group labels.
* Category B (Discrete): Fetches baseline from KiCad standard library, customizes properties.
*/
import { symGet } from '../../viewer/kicad-api-client.js';
import { existsSync, readFileSync, writeFileSync, mkdirSync } from 'fs';
import { join, dirname } from 'path';
import { fileURLToPath } from 'url';
// ── Baseline cache ──
// Caches fetched KiCad standard library symbols to avoid repeated service-kicad calls.
const __dirname = dirname(fileURLToPath(import.meta.url));
const CACHE_DIR = join(__dirname, '..', 'cache');
mkdirSync(CACHE_DIR, { recursive: true });
async function cachedSymGet(library, symbolName) {
const cacheFile = join(CACHE_DIR, `${library}--${symbolName}.kicad_sym`);
if (existsSync(cacheFile)) {
return readFileSync(cacheFile, 'utf-8');
}
const content = await symGet(library, symbolName);
writeFileSync(cacheFile, content, 'utf-8');
return content;
}
// Common discrete baselines to pre-warm cache on startup
const COMMON_BASELINES = [
{ library: 'Device', symbol: 'R' },
{ library: 'Device', symbol: 'C' },
{ library: 'Device', symbol: 'C_Polarized' },
{ library: 'Device', symbol: 'L' },
{ library: 'Device', symbol: 'D' },
{ library: 'Device', symbol: 'D_Zener' },
{ library: 'Device', symbol: 'LED' },
{ library: 'Transistor_FET', symbol: 'Q_NMOS_GSD' },
{ library: 'Transistor_FET', symbol: 'Q_NMOS_GDS' },
{ library: 'Transistor_FET', symbol: 'Q_PMOS_GSD' },
{ library: 'Transistor_FET', symbol: 'Q_PMOS_GDS' },
{ library: 'Transistor_BJT', symbol: 'Q_NPN_BCE' },
{ library: 'Transistor_BJT', symbol: 'Q_PNP_BCE' },
];
/**
* Pre-warm the baseline cache with common discrete components.
* Call once at service startup. Skips already-cached symbols.
*/
export async function warmBaselineCache() {
let cached = 0, fetched = 0, failed = 0;
for (const { library, symbol } of COMMON_BASELINES) {
const cacheFile = join(CACHE_DIR, `${library}--${symbol}.kicad_sym`);
if (existsSync(cacheFile)) { cached++; continue; }
try {
await cachedSymGet(library, symbol);
fetched++;
} catch (err) {
failed++;
}
}
return { cached, fetched, failed, total: COMMON_BASELINES.length };
}
// ── Pin angle constants ──
const ANGLE_LEFT_SIDE = 0; // Pin extends right from tip → left side of body
const ANGLE_RIGHT_SIDE = 180; // Pin extends left from tip → right side of body
const ANGLE_BOTTOM_SIDE = 90; // Pin extends up from tip → bottom side of body
const ANGLE_TOP_SIDE = 270; // Pin extends down from tip → top side of body
const PIN_LENGTH = 2.54;
const PIN_PITCH = 2.54; // Within a group
const GROUP_GAP = 3.81; // Between groups (1.5 grid steps — tight but clear separation)
const LABEL_OFFSET = 2.0; // Group label above first pin in group
const BODY_TOP_MARGIN = 2.54; // Margin above topmost group label (includes text height)
const BODY_BOTTOM_MARGIN = 2.54; // Below lowest pin
const MIN_BODY_WIDTH = 20; // Minimum body width (mm)
const BODY_PADDING = 4; // Horizontal padding inside body for pin names
// ── Standard font sizes (mm) — enforced by validation ──
// These are the canonical sizes for all symbols. kicad-validate.js checks conformance.
export const FONT_SIZES = {
pinName: 1.27, // Pin name text (KiCad default)
pinNumber: 1.27, // Pin number text (KiCad default)
property: 1.27, // Reference, Value, Footprint, Datasheet, etc.
groupLabel: 0.762, // Group label text (smaller, inside body)
chipName: 1.27, // Centered chip name (when enabled)
};
/**
* Generate a .kicad_sym file for a Category A (IC) component.
*
* @param {object} input
* @param {string} input.symbolName
* @param {string} input.manufacturer
* @param {string} input.package
* @param {string} input.description
* @param {string} input.datasheetUrl
* @param {string} [input.referencePrefix='U']
* @param {Array<{name: string, number: string, type: string, group?: string}>} input.pins
* @param {Array<{name: string}>} [input.groups]
* @param {object} [input.preferences]
* @param {string} [input.preferences.pinLayout='left-right']
* @param {boolean} [input.preferences.chipNameCentered=false]
* @param {string} [input.preferences.partNumber='none']
* @param {object} [input.preferences.distributorPn] - e.g. { mouser: 'XXX-XXXXX' }
* @returns {string} Complete .kicad_sym file content
*/
export function generateICSym(input) {
const {
symbolName,
manufacturer = '',
package: pkg = '',
description = '',
datasheetUrl = '',
referencePrefix = 'U',
pins,
groups = [],
preferences = {},
} = input;
const pinLayout = preferences.pinLayout || 'left-right';
const chipNameCentered = preferences.chipNameCentered || false;
// Group pins by their group name, preserving order
const groupMap = new Map();
for (const pin of pins) {
const g = pin.group || '_ungrouped';
if (!groupMap.has(g)) groupMap.set(g, []);
groupMap.get(g).push(pin);
}
// Decide which pins go on which side
const leftPins = [];
const rightPins = [];
const topPins = [];
const bottomPins = [];
for (const [groupName, groupPins] of groupMap) {
// Check if any pin in the group has an explicit side override
const explicitSide = groupPins[0]?.side; // All pins in a group share the same side
const allSameSide = explicitSide && groupPins.every(p => p.side === explicitSide);
if (allSameSide) {
// Explicit side override takes priority
if (explicitSide === 'left') leftPins.push({ groupName, pins: groupPins });
else if (explicitSide === 'right') rightPins.push({ groupName, pins: groupPins });
else if (explicitSide === 'top') topPins.push({ groupName, pins: groupPins });
else if (explicitSide === 'bottom') bottomPins.push({ groupName, pins: groupPins });
} else {
// Auto-determine side based on pin types and layout preference
const hasInputs = groupPins.some(p => ['input', 'bidirectional'].includes(p.type));
const hasPower = groupPins.some(p => ['power_in', 'power_out'].includes(p.type));
const hasOutputs = groupPins.some(p => ['output', 'tri_state'].includes(p.type));
const isGround = groupPins.every(p => p.type === 'power_in' && /^(GND|VSS|AGND|DGND|EP)/i.test(p.name));
if (pinLayout === 'all-sides') {
if (isGround) bottomPins.push({ groupName, pins: groupPins });
else if (hasPower && !hasInputs && !hasOutputs) topPins.push({ groupName, pins: groupPins });
else if (hasOutputs && !hasInputs) rightPins.push({ groupName, pins: groupPins });
else leftPins.push({ groupName, pins: groupPins });
} else {
// left-right only
if (isGround) leftPins.push({ groupName, pins: groupPins });
else if (hasOutputs && !hasInputs) rightPins.push({ groupName, pins: groupPins });
else if (hasPower && !hasInputs) rightPins.push({ groupName, pins: groupPins });
else leftPins.push({ groupName, pins: groupPins });
}
}
}
// Calculate Y positions for left/right side groups
function layoutVerticalSide(sideGroups) {
const positioned = [];
let y = 0;
for (let i = 0; i < sideGroups.length; i++) {
const { groupName, pins: gPins } = sideGroups[i];
if (i > 0) y -= GROUP_GAP;
// Group label position
const labelY = y;
y -= LABEL_OFFSET;
for (const pin of gPins) {
positioned.push({ ...pin, y });
y -= PIN_PITCH;
}
if (groupName !== '_ungrouped') {
positioned.labelPositions = positioned.labelPositions || [];
positioned.labelPositions.push({ name: groupName, y: labelY });
}
}
return { pins: positioned.filter(p => p.name), labels: positioned.labelPositions || [], totalHeight: Math.abs(y) };
}
// Calculate X positions for top/bottom side groups
// Mirrors layoutVerticalSide: label is placed to the LEFT of its pin group
// (analogous to how vertical labels are placed ABOVE their pin group)
function layoutHorizontalSide(sideGroups, startX) {
const positioned = [];
let x = startX;
for (let i = 0; i < sideGroups.length; i++) {
const { groupName, pins: gPins } = sideGroups[i];
if (i > 0) x += GROUP_GAP;
// Group label position (to the left of pins, like vertical labels are above pins)
const labelX = x;
if (groupName !== '_ungrouped') {
x += LABEL_OFFSET; // advance past label before placing pins
}
for (const pin of gPins) {
positioned.push({ ...pin, x });
x += PIN_PITCH;
}
if (groupName !== '_ungrouped') {
positioned.labelPositions = positioned.labelPositions || [];
positioned.labelPositions.push({ name: groupName, x: labelX });
}
}
return { pins: positioned.filter(p => p.name), labels: positioned.labelPositions || [], totalWidth: x - startX };
}
const leftLayout = layoutVerticalSide(leftPins);
const rightLayout = layoutVerticalSide(rightPins);
// Determine body dimensions — account for top/bottom pins
const hasTopBottom = topPins.length > 0 || bottomPins.length > 0;
// Body width based on longest pin names (for left-right layout) OR top/bottom pin count
const longestLeft = leftLayout.pins.reduce((max, p) => Math.max(max, p.name.length), 0);
const longestRight = rightLayout.pins.reduce((max, p) => Math.max(max, p.name.length), 0);
const nameWidth = (longestLeft + longestRight) * 1.0 + BODY_PADDING * 2;
// For top/bottom: compute required width from pin count + group gaps + label offsets
const topPinCount = topPins.reduce((s, g) => s + g.pins.length, 0);
const bottomPinCount = bottomPins.reduce((s, g) => s + g.pins.length, 0);
const topGroupGaps = Math.max(0, topPins.length - 1) * GROUP_GAP;
const bottomGroupGaps = Math.max(0, bottomPins.length - 1) * GROUP_GAP;
const topLabelOffsets = topPins.filter(g => g.groupName !== '_ungrouped').length * LABEL_OFFSET;
const bottomLabelOffsets = bottomPins.filter(g => g.groupName !== '_ungrouped').length * LABEL_OFFSET;
const topWidth = topPinCount * PIN_PITCH + topGroupGaps + topLabelOffsets + BODY_PADDING * 2;
const bottomWidth = bottomPinCount * PIN_PITCH + bottomGroupGaps + bottomLabelOffsets + BODY_PADDING * 2;
const bodyWidth = Math.max(nameWidth, MIN_BODY_WIDTH, topWidth, bottomWidth);
const halfWidth = bodyWidth / 2;
// Round to grid
const bodyLeft = -Math.ceil(halfWidth / 2.54) * 2.54;
const bodyRight = Math.ceil(halfWidth / 2.54) * 2.54;
// Layout top/bottom pins centered within the body
// First pass: compute total width starting at 0
const topDry = layoutHorizontalSide(topPins, 0);
const bottomDry = layoutHorizontalSide(bottomPins, 0);
// Center: shift so pin group is centered between bodyLeft and bodyRight
const topStartX = -topDry.totalWidth / 2;
const bottomStartX = -bottomDry.totalWidth / 2;
const topLayout = layoutHorizontalSide(topPins, topStartX);
const bottomLayout = layoutHorizontalSide(bottomPins, bottomStartX);
// Find topmost label Y for top margin
const allLabels = [...leftLayout.labels, ...rightLayout.labels];
const topLabelY = allLabels.length > 0 ? Math.max(...allLabels.map(l => l.y)) : 0;
// Extra clearance when top/bottom pins exist — their names and group labels
// render vertically inside the body, so we need room at those edges.
let topPinClearance = 0;
let bottomPinClearance = 0;
if (topLayout.pins.length > 0) {
const longestTopName = topLayout.pins.reduce((max, p) => Math.max(max, p.name.length), 0);
const longestTopLabel = topLayout.labels.reduce((max, l) => Math.max(max, l.name.length), 0);
topPinClearance = Math.max(longestTopName * FONT_SIZES.pinName * 0.7, longestTopLabel * FONT_SIZES.groupLabel * 0.7) + 2;
}
if (bottomLayout.pins.length > 0) {
const longestBottomName = bottomLayout.pins.reduce((max, p) => Math.max(max, p.name.length), 0);
const longestBottomLabel = bottomLayout.labels.reduce((max, l) => Math.max(max, l.name.length), 0);
// Extra 0.5mm for KiCad pin name text overshoot at body edge
bottomPinClearance = Math.max(longestBottomName * FONT_SIZES.pinName * 0.7, longestBottomLabel * FONT_SIZES.groupLabel * 0.7) + 2.5;
}
const bodyTop = topLabelY + BODY_TOP_MARGIN + topPinClearance;
// Find lowest pin Y for bottom margin
const allPinYs = [...leftLayout.pins, ...rightLayout.pins].map(p => p.y);
const lowestPinY = allPinYs.length > 0 ? Math.min(...allPinYs) : -10;
const bodyBottom = lowestPinY - BODY_BOTTOM_MARGIN - bottomPinClearance;
// Build the .kicad_sym content
const lines = [];
lines.push('(kicad_symbol_lib');
lines.push(' (version 20231120)');
lines.push(' (generator "adom")');
lines.push(` (symbol "${symbolName}"`);
lines.push(' (pin_names (offset 1.016))');
lines.push(' (exclude_from_sim no)');
lines.push(' (in_bom yes)');
lines.push(' (on_board yes)');
// Properties
lines.push(` (property "Reference" "${referencePrefix}"`);
lines.push(` (at 0 ${(bodyTop + 2.54).toFixed(2)} 0)`);
lines.push(' (effects (font (size 1.27 1.27)))');
lines.push(' )');
lines.push(` (property "Value" "${symbolName}"`);
lines.push(` (at 0 ${(bodyBottom - 2.54).toFixed(2)} 0)`);
lines.push(' (effects (font (size 1.27 1.27)))');
lines.push(' )');
lines.push(` (property "Footprint" "${pkg}"`);
lines.push(' (at 0 0 0)');
lines.push(' (effects (font (size 1.27 1.27)) (hide yes))');
lines.push(' )');
lines.push(` (property "Datasheet" "${datasheetUrl}"`);
lines.push(' (at 0 0 0)');
lines.push(' (effects (font (size 1.27 1.27)) (hide yes))');
lines.push(' )');
lines.push(` (property "Description" "${description}"`);
lines.push(' (at 0 0 0)');
lines.push(' (effects (font (size 1.27 1.27)) (hide yes))');
lines.push(' )');
lines.push(` (property "Manufacturer" "${manufacturer}"`);
lines.push(' (at 0 0 0)');
lines.push(' (effects (font (size 1.27 1.27)) (hide yes))');
lines.push(' )');
// Distributor part numbers
if (preferences.distributorPn) {
for (const [dist, pn] of Object.entries(preferences.distributorPn)) {
const label = dist.charAt(0).toUpperCase() + dist.slice(1);
lines.push(` (property "${label}" "${pn}"`);
lines.push(' (at 0 0 0)');
lines.push(' (effects (font (size 1.27 1.27)) (hide yes))');
lines.push(' )');
}
}
// Graphics sub-symbol (_0_1)
lines.push(` (symbol "${symbolName}_0_1"`);
// Body rectangle
lines.push(` (rectangle (start ${bodyLeft.toFixed(2)} ${bodyTop.toFixed(2)}) (end ${bodyRight.toFixed(2)} ${bodyBottom.toFixed(2)})`);
lines.push(' (stroke (width 0.254) (type default))');
lines.push(' (fill (type background))');
lines.push(' )');
// Group labels (left side)
for (const label of leftLayout.labels) {
lines.push(` (text "${label.name}" (at ${(bodyLeft + 1.02).toFixed(2)} ${label.y.toFixed(2)} 0)`);
lines.push(' (effects (font (size 0.762 0.762) (color 132 0 0 1)) (justify left))');
lines.push(' )');
}
// Group labels (right side)
for (const label of rightLayout.labels) {
lines.push(` (text "${label.name}" (at ${(bodyRight - 1.02).toFixed(2)} ${label.y.toFixed(2)} 0)`);
lines.push(' (effects (font (size 0.762 0.762) (color 132 0 0 1)) (justify right))');
lines.push(' )');
}
// Group labels (top side) — placed just inside the top edge, text extends downward into body
// At 90° rotation, "justify right" makes text extend in -Y direction (into body)
for (const label of topLayout.labels) {
lines.push(` (text "${label.name}" (at ${label.x.toFixed(2)} ${(bodyTop - 1.02).toFixed(2)} 900)`);
lines.push(' (effects (font (size 0.762 0.762) (color 132 0 0 1)) (justify right))');
lines.push(' )');
}
// Group labels (bottom side) — placed just inside the bottom edge, text extends upward into body
// At 90° rotation, "justify left" makes text extend in +Y direction (into body)
for (const label of bottomLayout.labels) {
lines.push(` (text "${label.name}" (at ${label.x.toFixed(2)} ${(bodyBottom + 1.02).toFixed(2)} 900)`);
lines.push(' (effects (font (size 0.762 0.762) (color 132 0 0 1)) (justify left))');
lines.push(' )');
}
// Centered chip name (if preference enabled)
if (chipNameCentered) {
const centerY = ((bodyTop + bodyBottom) / 2).toFixed(2);
lines.push(` (text "${symbolName}" (at 0 ${centerY} 0)`);
lines.push(' (effects (font (size 1.27 1.27) (color 100 100 100 1)) (justify center))');
lines.push(' )');
}
lines.push(' )');
// Pins sub-symbol (_1_1)
lines.push(` (symbol "${symbolName}_1_1"`);
// Left-side pins
for (const pin of leftLayout.pins) {
const tipX = bodyLeft - PIN_LENGTH;
lines.push(` (pin ${pin.type} line (at ${tipX.toFixed(2)} ${pin.y.toFixed(2)} ${ANGLE_LEFT_SIDE}) (length ${PIN_LENGTH}) (name "${pin.name}") (number "${pin.number}"))`);
}
// Right-side pins
for (const pin of rightLayout.pins) {
const tipX = bodyRight + PIN_LENGTH;
lines.push(` (pin ${pin.type} line (at ${tipX.toFixed(2)} ${pin.y.toFixed(2)} ${ANGLE_RIGHT_SIDE}) (length ${PIN_LENGTH}) (name "${pin.name}") (number "${pin.number}"))`);
}
// Top-side pins — enter body 0.1mm inside top edge to prevent name text overshoot
if (topLayout.pins.length > 0) {
const topPinEntry = bodyTop - 0.1;
for (const pin of topLayout.pins) {
const tipY = topPinEntry + PIN_LENGTH;
lines.push(` (pin ${pin.type} line (at ${pin.x.toFixed(2)} ${tipY.toFixed(2)} ${ANGLE_TOP_SIDE}) (length ${PIN_LENGTH}) (name "${pin.name}") (number "${pin.number}"))`);
}
}
// Bottom-side pins — enter body 0.1mm inside bottom edge to prevent name text overshoot
if (bottomLayout.pins.length > 0) {
const bottomPinEntry = bodyBottom + 0.1;
for (const pin of bottomLayout.pins) {
const tipY = bottomPinEntry - PIN_LENGTH;
lines.push(` (pin ${pin.type} line (at ${pin.x.toFixed(2)} ${tipY.toFixed(2)} ${ANGLE_BOTTOM_SIDE}) (length ${PIN_LENGTH}) (name "${pin.name}") (number "${pin.number}"))`);
}
}
lines.push(' )');
lines.push(' )');
lines.push(')');
lines.push('');
return lines.join('\n');
}
/**
* Generate a .kicad_sym file for a Category B (discrete) component.
*
* Handles the `extends` mechanism by building from scratch:
* takes parent's graphics + pins, applies child's properties.
*
* @param {object} input
* @param {string} input.symbolName
* @param {string} input.manufacturer
* @param {string} input.package
* @param {string} input.description
* @param {string} input.datasheetUrl
* @param {string} [input.referencePrefix]
* @param {object} input.discreteBaseline - { library, symbol }
* @param {Array} [input.pinOverrides] - Override pin numbers/names
* @returns {Promise<string>} Complete .kicad_sym file content
*/
export async function generateDiscreteSym(input) {
const {
symbolName,
manufacturer = '',
package: pkg = '',
description = '',
datasheetUrl = '',
referencePrefix,
discreteBaseline,
pinOverrides = [],
} = input;
// Fetch baseline from KiCad service (cached locally after first fetch)
let baseline = await cachedSymGet(discreteBaseline.library, discreteBaseline.symbol);
// Check if it uses extends — need to fetch parent too
const extendsMatch = baseline.match(/\(extends "([^"]+)"\)/);
let parentContent = null;
let parentName = null;
if (extendsMatch) {
parentName = extendsMatch[1];
parentContent = await cachedSymGet(discreteBaseline.library, parentName);
}
// The source of graphics and pins: parent (if extends) or baseline itself
const graphicsSource = parentContent || baseline;
const graphicsSourceName = parentName || discreteBaseline.symbol;
// Extract sub-symbol blocks from the graphics source
const graphicsBlock = extractSubSymbol(graphicsSource, graphicsSourceName, '_0_1');
const pinsBlock = extractSubSymbol(graphicsSource, graphicsSourceName, '_1_1');
// Extract header settings from the graphics source
const header = extractSymbolHeader(graphicsSource);
// Get reference prefix from the graphics source or override
let refPrefix = referencePrefix;
if (!refPrefix) {
const refMatch = graphicsSource.match(/\(property "Reference" "([^"]+)"/);
refPrefix = refMatch ? refMatch[1] : 'U';
}
// Get property values — child properties override parent
const propValue = symbolName;
const propFootprint = pkg || extractProperty(baseline, 'Footprint') || extractProperty(graphicsSource, 'Footprint') || '';
const propDatasheet = datasheetUrl || extractProperty(baseline, 'Datasheet') || '';
const propDescription = description || extractProperty(baseline, 'Description') || '';
const propManufacturer = manufacturer || extractProperty(baseline, 'Manufacturer') || '';
// Get position hints from baseline for Reference/Value property placement
const refAt = extractPropertyAt(graphicsSource, 'Reference') || '0 2.54 0';
const valAt = extractPropertyAt(graphicsSource, 'Value') || '0 -2.54 0';
const refEffects = extractPropertyEffects(graphicsSource, 'Reference') || '(effects (font (size 1.27 1.27)))';
const valEffects = extractPropertyEffects(graphicsSource, 'Value') || '(effects (font (size 1.27 1.27)))';
// Rename sub-symbol blocks to the new symbol name
let renamedGraphics = graphicsBlock
.replace(`(symbol "${graphicsSourceName}_0_1"`, `(symbol "${symbolName}_0_1"`);
let renamedPins = pinsBlock
.replace(`(symbol "${graphicsSourceName}_1_1"`, `(symbol "${symbolName}_1_1"`);
// Apply pin overrides
for (const override of pinOverrides) {
if (override.oldNumber && override.newNumber) {
renamedPins = renamedPins.replace(
new RegExp(`\\(number "${escRegex(override.oldNumber)}"`, 'g'),
`(number "${override.newNumber}"`
);
}
if (override.oldName && override.newName) {
renamedPins = renamedPins.replace(
new RegExp(`\\(name "${escRegex(override.oldName)}"`, 'g'),
`(name "${override.newName}"`
);
}
}
// Build the complete file from scratch
const lines = [];
lines.push('(kicad_symbol_lib');
lines.push(' (version 20231120)');
lines.push(' (generator "adom")');
lines.push(` (symbol "${symbolName}"`);
// Header settings
if (header.hidePinNames) {
lines.push(` (pin_numbers (hide yes))`);
lines.push(` (pin_names (offset ${header.pinNameOffset}) (hide yes))`);
} else {
lines.push(` (pin_names (offset ${header.pinNameOffset}))`);
}
lines.push(' (exclude_from_sim no)');
lines.push(' (in_bom yes)');
lines.push(' (on_board yes)');
// Properties (clean, single-line format)
lines.push(` (property "Reference" "${refPrefix}"`);
lines.push(` (at ${refAt})`);
lines.push(` ${refEffects}`);
lines.push(' )');
lines.push(` (property "Value" "${propValue}"`);
lines.push(` (at ${valAt})`);
lines.push(` ${valEffects}`);
lines.push(' )');
lines.push(` (property "Footprint" "${propFootprint}"`);
lines.push(' (at 0 0 0)');
lines.push(' (effects (font (size 1.27 1.27)) (hide yes))');
lines.push(' )');
lines.push(` (property "Datasheet" "${propDatasheet}"`);
lines.push(' (at 0 0 0)');
lines.push(' (effects (font (size 1.27 1.27)) (hide yes))');
lines.push(' )');
lines.push(` (property "Description" "${propDescription}"`);
lines.push(' (at 0 0 0)');
lines.push(' (effects (font (size 1.27 1.27)) (hide yes))');
lines.push(' )');
if (propManufacturer) {
lines.push(` (property "Manufacturer" "${propManufacturer}"`);
lines.push(' (at 0 0 0)');
lines.push(' (effects (font (size 1.27 1.27)) (hide yes))');
lines.push(' )');
}
// Graphics sub-symbol
lines.push(renamedGraphics);
// Pins sub-symbol
lines.push(renamedPins);
lines.push(' )');
lines.push(')');
lines.push('');
return lines.join('\n');
}
// ── Discrete helpers ──
function extractSubSymbol(content, symbolName, suffix) {
const target = `(symbol "${symbolName}${suffix}"`;
const start = content.indexOf(target);
if (start === -1) return '';
let depth = 0;
let i = start;
for (; i < content.length; i++) {
if (content[i] === '(') depth++;
else if (content[i] === ')') {
depth--;
if (depth === 0) break;
}
}
const lineStart = content.lastIndexOf('\n', start) + 1;
return content.slice(lineStart, i + 1);
}
function extractSymbolHeader(content) {
const header = { hidePinNames: false, hidePinNumbers: false, pinNameOffset: 1.016 };
if (/\(pin_names[\s\S]*?\(hide\s+yes\)/.test(content)) header.hidePinNames = true;
if (/\(pin_numbers[\s\S]*?\(hide\s+yes\)/.test(content)) header.hidePinNumbers = true;
const offsetMatch = content.match(/\(pin_names\s*\n?\s*\(offset\s+([\d.]+)\)/);
if (offsetMatch) header.pinNameOffset = parseFloat(offsetMatch[1]);
return header;
}
function extractProperty(content, propName) {
const re = new RegExp(`\\(property "${propName}" "([^"]*)"`);
const m = content.match(re);
return m ? m[1] : '';
}
function extractPropertyAt(content, propName) {
// Find (property "NAME" "..." \n (at X Y Z) and extract "X Y Z"
const re = new RegExp(`\\(property "${propName}" "[^"]*"\\s*\\n\\s*\\(at\\s+([^)]+)\\)`);
const m = content.match(re);
return m ? m[1].trim() : null;
}
function extractPropertyEffects(content, propName) {
// Find the effects block for a property
const propStart = content.indexOf(`(property "${propName}"`);
if (propStart === -1) return null;
// Find the (effects ...) after this property
const effectsStart = content.indexOf('(effects', propStart);
if (effectsStart === -1) return null;
// Make sure it's within the property block
const propEnd = findMatchingParen(content, propStart);
if (effectsStart > propEnd) return null;
const effectsEnd = findMatchingParen(content, effectsStart);
return content.slice(effectsStart, effectsEnd + 1);
}
function findMatchingParen(content, start) {
let depth = 0;
for (let i = start; i < content.length; i++) {
if (content[i] === '(') depth++;
else if (content[i] === ')') {
depth--;
if (depth === 0) return i;
}
}
return content.length;
}
// ── Helpers ──
function replaceProperty(content, propName, newValue) {
const re = new RegExp(`(\\(property "${propName}" )"[^"]*"`, 'g');
return content.replace(re, `$1"${newValue}"`);
}
/**
* Safe property replacement that handles multi-line KiCad format.
* Finds `(property "NAME" "OLD_VALUE"` and replaces just the value string.
*/
function replacePropertySafe(content, propName, newValue) {
// Match (property "PropName" "anything" — just replace the value part
const re = new RegExp(`(\\(property "${escRegex(propName)}" )"[^"]*"`, 'g');
return content.replace(re, `$1"${newValue}"`);
}
/**
* Remove an entire property block from the content.
* Handles multi-line property blocks with nested parentheses.
*/
function removeProperty(content, propName) {
const marker = `(property "${propName}"`;
let idx = content.indexOf(marker);
while (idx !== -1) {
// Find the start of the line
const lineStart = content.lastIndexOf('\n', idx);
// Find the end of the property block (matched parentheses)
let depth = 0;
let end = idx;
for (; end < content.length; end++) {
if (content[end] === '(') depth++;
else if (content[end] === ')') {
depth--;
if (depth === 0) break;
}
}
// Remove from line start to end of property (including trailing newline)
const nextNewline = content.indexOf('\n', end);
const cutEnd = nextNewline !== -1 ? nextNewline + 1 : end + 1;
content = content.slice(0, lineStart === -1 ? idx : lineStart) + content.slice(cutEnd);
idx = content.indexOf(marker);
}
return content;
}
function escRegex(str) {
return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
}