feat(libs): standalone .lib→.kicad_sym wasm converter (sym_convert)

Headless node-WASM CLI that converts legacy symbol libraries to .kicad_sym via
SCH_IO_MGR::ConvertLibrary. Adds wasm/cli/sym_convert_main.cpp + sym_convert_pre.js
(in-memory localStorage + wxConfig JS-hook shim), registers sym_convert as a
docker/build.sh app (own kicad-sym_convert tree, real wasm-opt/finalize in-container,
skip host postprocess), and bumps the kicad pointer for the matching target.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Gergő Törcsvári 2026-06-18 12:21:15 +02:00
commit 698f6d7cb7
No known key found for this signature in database
GPG key ID: 8E75F2CDE64E5322
6 changed files with 258 additions and 32 deletions

View file

@ -61,7 +61,7 @@ trap 'kw_fail 130; exit 130' INT TERM
cd "$(dirname "$0")/.."
VALID_APPS="pcbnew | eeschema | calculator | pl_editor | symbol_editor | footprint_editor | gerbview | all"
VALID_APPS="pcbnew | eeschema | calculator | pl_editor | symbol_editor | footprint_editor | gerbview | sym_convert | all"
usage() {
echo "Usage: ./docker/build.sh <app>[,<app>...] [args...]" >&2
@ -92,7 +92,7 @@ else
IFS=',' read -r -a APPS <<< "$APP_NAME"
for app in "${APPS[@]}"; do
case "$app" in
pcbnew|eeschema|calculator|pl_editor|symbol_editor|footprint_editor|gerbview) ;;
pcbnew|eeschema|calculator|pl_editor|symbol_editor|footprint_editor|gerbview|sym_convert) ;;
*)
echo "Error: unknown app '$app' (expected: ${VALID_APPS})" >&2
usage
@ -163,6 +163,7 @@ kicad_subdir_for() {
pl_editor) echo "pagelayout_editor" ;;
symbol_editor) echo "eeschema" ;;
footprint_editor) echo "pcbnew" ;;
sym_convert) echo "eeschema" ;;
*) echo "$1" ;;
esac
}
@ -218,6 +219,13 @@ postprocess_app() {
local app="$1"
local out_dir="output"
# The converter is finalized in-container (real tools, small -g0 wasm) and is
# a synchronous node CLI, so it needs no host post-processing.
if [ "$app" = "sym_convert" ]; then
echo "Skipping host post-processing for ${app} (finalized in-container)"
return 0
fi
# Inject dynCall shims (fixes "dynCall_* is not defined" errors in Emscripten 4.x)
kw_stage dyncall-shims
./scripts/common/inject-dyncall-shims.sh "${out_dir}/${app}.js"
@ -226,9 +234,12 @@ postprocess_app() {
kw_stage finalize
./scripts/common/apply-finalize.sh "${out_dir}/${app}.wasm" "${out_dir}/${app}.wasm"
# Apply asyncify transformation on host
kw_stage asyncify
./scripts/common/apply-asyncify.sh "${out_dir}/${app}.wasm" "${out_dir}/${app}.wasm"
# Apply asyncify transformation on host. The converter is a synchronous node
# CLI built with ASYNCIFY=0, so asyncify is unnecessary and would be wrong.
if [ "$app" != "sym_convert" ]; then
kw_stage asyncify
./scripts/common/apply-asyncify.sh "${out_dir}/${app}.wasm" "${out_dir}/${app}.wasm"
fi
}
# --- Pipelined driver state (KICAD_PIPELINE=1) ---

2
kicad

@ -1 +1 @@
Subproject commit cb82b64410afac19018be16a3c3ef652db6928c1
Subproject commit b75cff2f5c1fcf121a47af7cf2bec13f0389505d

View file

@ -65,8 +65,15 @@ case "$APP_NAME" in
KICAD_TARGET="footprint_editor"
KICAD_SUBDIR="pcbnew"
;;
sym_convert)
# Standalone .lib -> .kicad_sym converter (node CLI). Its add_executable
# lives in eeschema/CMakeLists.txt (gated by KICAD_SYM_CONVERTER_WASM), so
# artifacts land in the eeschema/ subdir of its own kicad-sym_convert tree.
KICAD_TARGET="sym_convert"
KICAD_SUBDIR="eeschema"
;;
*)
echo "Error: unknown app '$APP_NAME' (expected: pcbnew | eeschema | calculator | pl_editor | symbol_editor | footprint_editor | gerbview)" >&2
echo "Error: unknown app '$APP_NAME' (expected: pcbnew | eeschema | calculator | pl_editor | symbol_editor | footprint_editor | gerbview | sym_convert)" >&2
exit 1
;;
esac
@ -79,6 +86,9 @@ esac
case "$APP_NAME" in
symbol_editor) EMBIND_APP="eeschema" ;;
footprint_editor) EMBIND_APP="pcbnew" ;;
# sym_convert links the eeschema kiface objects, which reference eeschema's
# embind symbols (kicadCollabOnSave et al.) — reuse eeschema's embind object.
sym_convert) EMBIND_APP="eeschema" ;;
*) EMBIND_APP="$APP_NAME" ;;
esac
@ -88,6 +98,9 @@ esac
# (pcbnewGetScriptsSearchPaths et al., defined in pcbnew_scripting_stub.cpp).
case "$APP_NAME" in
footprint_editor) STUB_APP="pcbnew" ;;
# sym_convert links the eeschema kiface objects, so it needs eeschema's
# frame stub (eeschema_frame_stub.cpp) like symbol_editor does.
sym_convert) STUB_APP="eeschema" ;;
*) STUB_APP="$APP_NAME" ;;
esac
@ -295,34 +308,42 @@ fi
log_info "Stub libraries built"
# Step 6.2: Replace Emscripten's wasm-opt with stub to bypass asyncify transformation
# This allows Emscripten to generate JS with Asyncify runtime, but we run the real
# wasm-opt --asyncify on the host where more RAM is available (needs 50GB+ for KiCad)
# Step 6.2/6.3: wasm-opt + wasm-emscripten-finalize handling.
# For the editor apps these tools OOM on the huge debug wasm, so we stub them in
# the container and run them on the host (docker/build.sh phase 2). The small,
# debug-stripped (-g0) converter finalizes fine in-container, so for sym_convert
# we restore/keep the real tools and skip host post-processing entirely.
if [ -z "${EMSDK}" ]; then
log_error "EMSDK environment variable is not set."
exit 1
fi
EMSDK_WASM_OPT="${EMSDK}/upstream/bin/wasm-opt"
if [ -f "${EMSDK_WASM_OPT}" ] && [ ! -f "${EMSDK_WASM_OPT}.real" ]; then
log_info "Backing up real wasm-opt..."
mv "${EMSDK_WASM_OPT}" "${EMSDK_WASM_OPT}.real"
fi
# Always copy the latest stub (in case it was updated)
cp "${STUBS_DIR}/wasm-opt-stub.sh" "${EMSDK_WASM_OPT}"
chmod +x "${EMSDK_WASM_OPT}"
log_info "wasm-opt stub installed (asyncify will run on host)"
# Step 6.3: Replace wasm-emscripten-finalize with stub (same pattern as wasm-opt)
# This tool also OOMs on large WASM with debug symbols, so we run it on the host
EMSDK_FINALIZE="${EMSDK}/upstream/bin/wasm-emscripten-finalize"
if [ -f "${EMSDK_FINALIZE}" ] && [ ! -f "${EMSDK_FINALIZE}.real" ]; then
log_info "Backing up real wasm-emscripten-finalize..."
mv "${EMSDK_FINALIZE}" "${EMSDK_FINALIZE}.real"
if [ "${APP_NAME}" = "sym_convert" ]; then
# Use the real tools so the converter is fully finalized inside the container.
[ -f "${EMSDK_WASM_OPT}.real" ] && cp "${EMSDK_WASM_OPT}.real" "${EMSDK_WASM_OPT}"
[ -f "${EMSDK_FINALIZE}.real" ] && cp "${EMSDK_FINALIZE}.real" "${EMSDK_FINALIZE}"
log_info "Using real wasm-opt/finalize for sym_convert (finalize in-container)"
else
if [ -f "${EMSDK_WASM_OPT}" ] && [ ! -f "${EMSDK_WASM_OPT}.real" ]; then
log_info "Backing up real wasm-opt..."
mv "${EMSDK_WASM_OPT}" "${EMSDK_WASM_OPT}.real"
fi
# Always copy the latest stub (in case it was updated)
cp "${STUBS_DIR}/wasm-opt-stub.sh" "${EMSDK_WASM_OPT}"
chmod +x "${EMSDK_WASM_OPT}"
log_info "wasm-opt stub installed (asyncify will run on host)"
if [ -f "${EMSDK_FINALIZE}" ] && [ ! -f "${EMSDK_FINALIZE}.real" ]; then
log_info "Backing up real wasm-emscripten-finalize..."
mv "${EMSDK_FINALIZE}" "${EMSDK_FINALIZE}.real"
fi
# Always copy the latest stub (in case it was updated)
cp "${STUBS_DIR}/wasm-emscripten-finalize-stub.sh" "${EMSDK_FINALIZE}"
chmod +x "${EMSDK_FINALIZE}"
log_info "wasm-emscripten-finalize stub installed (finalize will run on host)"
fi
# Always copy the latest stub (in case it was updated)
cp "${STUBS_DIR}/wasm-emscripten-finalize-stub.sh" "${EMSDK_FINALIZE}"
chmod +x "${EMSDK_FINALIZE}"
log_info "wasm-emscripten-finalize stub installed (finalize will run on host)"
# Step 6.5: Verify WASM support is in KiCad fork
# The kicad submodule should already have WASM port detection and kiplatform support
@ -359,8 +380,16 @@ if command -v ccache &> /dev/null; then
log_info "Using ccache for compilation"
fi
# The standalone converter is a gated eeschema target; enabling the option also
# trims the SCH_IO factory to the two KiCad plugins (no pcbjam/http) for this tree.
SYM_CONVERTER_CMAKE_FLAG=""
if [ "${APP_NAME}" = "sym_convert" ]; then
SYM_CONVERTER_CMAKE_FLAG="-DKICAD_SYM_CONVERTER_WASM=ON"
fi
emcmake cmake "${KICAD_DIR}" \
${CCACHE_OPTS} \
${SYM_CONVERTER_CMAKE_FLAG} \
-DCMAKE_BUILD_TYPE=${BUILD_TYPE} \
-DCMAKE_INSTALL_PREFIX="${SYSROOT}" \
-DCMAKE_MODULE_PATH="${WASM_LAYER}/cmake" \
@ -475,10 +504,13 @@ fi
emmake make -j${JOBS} "${KICAD_TARGET}"
# Step 8.1: Build bitmap resources (images.tar.gz)
# This creates the icon archive that KiCad loads at runtime
kw_stage kicad-bitmaps
log_info "Building bitmap resources..."
emmake make bitmap_archive_build
# This creates the icon archive that KiCad loads at runtime. The headless
# converter has no GUI/icons, so skip it.
if [ "${APP_NAME}" != "sym_convert" ]; then
kw_stage kicad-bitmaps
log_info "Building bitmap resources..."
emmake make bitmap_archive_build
fi
# Step 9: Create stamp file
create_stamp "${KICAD_STAMP}"

View file

@ -0,0 +1,6 @@
#!/bin/bash
# Build the standalone .lib -> .kicad_sym converter (sym_convert) for WebAssembly,
# as a node CLI. Thin wrapper around build-kicad-target.sh — see that script for options.
set -e
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
exec "${SCRIPT_DIR}/build-kicad-target.sh" sym_convert "$@"

View file

@ -0,0 +1,61 @@
/*
* sym_convert standalone CLI for legacy (.lib) -> S-expression (.kicad_sym)
* symbol-library conversion, built as a WebAssembly module.
*
* It drives SCH_IO_MGR::ConvertLibrary directly from main(): no GUI, no
* renderer, no embind bindings, no JS host logic. Wired into
* eeschema/CMakeLists.txt behind the KICAD_SYM_CONVERTER_WASM option
* (see scripts/kicad/build-sym-convert-wasm.sh).
*
* Targets:
* - Node: built with -sENVIRONMENT=node -sNODERAWFS=1 (the default here);
* run with `node sym_convert.js in.lib out.kicad_sym`.
* - wasmtime: a follow-up build (no pthreads, -sSTANDALONE_WASM) lets the
* same code run host-less under `wasmtime sym_convert.wasm ...`.
*
* GPL note: this is GPL KiCad code. The artifact is meant to be invoked as a
* separate process from the closed ingester, never linked into closed code.
*/
#include <cstdio>
#include <wx/init.h>
#include <wx/string.h>
#include <sch_io/sch_io_mgr.h>
int main( int argc, char** argv )
{
if( argc < 3 )
{
std::fprintf( stderr, "usage: sym_convert <input.lib> <output.kicad_sym>\n" );
return 2;
}
// Bring up wxBase (no GUI): wxString / wxFileName / wxFFile rely on the
// library being initialised. If the conversion path turns out to need more
// global state (PGM_BASE, settings), the run will surface it here.
wxInitializer initializer( argc, argv );
if( !initializer.IsOk() )
{
std::fprintf( stderr, "sym_convert: wxWidgets initialisation failed\n" );
return 3;
}
const wxString inPath = wxString::FromUTF8( argv[1] );
const wxString outPath = wxString::FromUTF8( argv[2] );
// aOldFileProps = nullptr: no library-table properties; ConvertLibrary
// guesses the source format from the path and writes the SCH_KICAD format.
const bool ok = SCH_IO_MGR::ConvertLibrary( nullptr, inPath, outPath );
if( ok )
{
std::fprintf( stderr, "sym_convert: OK %s -> %s\n", argv[1], argv[2] );
return 0;
}
std::fprintf( stderr, "sym_convert: FAILED to convert %s\n", argv[1] );
return 1;
}

116
wasm/cli/sym_convert_pre.js Normal file
View file

@ -0,0 +1,116 @@
// Host shims for the standalone sym_convert node CLI (emscripten --pre-js).
//
// KiCad's wxWidgets wasm port reads/writes settings through wxConfig, which
// bridges to JS hooks (getConfigEntryLength, …). The web editor provides those
// via wx.js, backed by the browser's localStorage. The headless converter has no
// browser/localStorage and needs no persisted settings, so we back the same hooks
// with an in-memory store: every read returns "absent" → KiCad falls back to
// defaults; writes live only for the process lifetime. Semantics mirror
// wxwidgets/build/wasm/wx.js exactly (sans persistence).
(function( g ) {
if( !g.localStorage )
{
var store = new Map();
g.localStorage = {
get length() { return store.size; },
key: function( i ) { var k = Array.from( store.keys() )[i]; return k === undefined ? null : k; },
getItem: function( k ) { return store.has( k ) ? store.get( k ) : null; },
setItem: function( k, v ) { store.set( String( k ), String( v ) ); },
removeItem: function( k ) { store.delete( k ); },
clear: function() { store.clear(); },
};
}
var ls = g.localStorage;
// Only reached once a stored value exists; with the empty in-memory store these
// never run, but keep them correct in case settings are written then read back.
var s2u = function( str, buf, len ) {
var f = g.stringToUTF8 || ( g.Module && g.Module.stringToUTF8 );
f( str, buf, len );
};
g.hasConfigEntry = function( key ) { return ls.getItem( key ) !== null; };
g.hasConfigGroup = function( key ) {
for( var i = 0; i < ls.length; i++ ) if( ls.key( i ).startsWith( key ) ) return true;
return false;
};
g.getConfigEntryCount = function( prefix, recurse ) {
var n = 0;
for( var i = 0; i < ls.length; i++ ) {
var key = ls.key( i );
if( key.startsWith( prefix ) ) {
var end = key.indexOf( '/', prefix.length );
if( end == -1 || recurse ) ++n;
}
}
return n;
};
g.getConfigEntryIndex = function( prefix, index ) {
var n = 0;
for( var i = 0; i < ls.length; i++ ) {
var key = ls.key( i );
if( key.startsWith( prefix ) ) {
var end = key.indexOf( '/', prefix.length );
if( end == -1 ) { if( n >= index ) return i; else ++n; }
}
}
return -1;
};
g.getConfigGroupCount = function( prefix, recurse ) {
var c = new Set();
for( var i = 0; i < ls.length; i++ ) {
var key = ls.key( i );
if( key.startsWith( prefix ) ) {
var end = key.indexOf( '/', prefix.length );
if( end != -1 ) { if( recurse ) end = key.lastIndexOf( '/' ); c.add( key.substring( prefix.length, end ) ); }
}
}
return c.size;
};
g.getConfigGroupIndex = function( prefix, index ) {
var c = new Set();
for( var i = 0; i < ls.length; i++ ) {
var key = ls.key( i );
if( key.startsWith( prefix ) ) {
var end = key.indexOf( '/', prefix.length );
if( end != -1 ) {
var child = key.substring( prefix.length, end );
if( !c.has( child ) ) { if( c.size >= index ) return i; else c.add( child ); }
}
}
}
return -1;
};
g.getConfigKeyLength = function( index ) { var k = ls.key( index ); return k ? k.length : 0; };
g.getConfigKey = function( index, buf, len ) { s2u( ls.key( index ), buf, len ); };
g.getConfigEntryLength = function( key ) { var v = ls.getItem( key ); return v === null ? -1 : v.length; };
g.getConfigEntry = function( key, buf, len ) {
var v = ls.getItem( key );
if( v !== null ) { s2u( v, buf, len ); return true; }
return false;
};
g.setConfigEntry = function( key, value ) { ls.setItem( key, value ); };
g.renameConfigGroup = function( oldG, newG ) {
var keys = [];
for( var i = 0; i < ls.length; i++ ) {
var key = ls.key( i );
if( key.startsWith( oldG ) ) keys.push( key );
else if( key.startsWith( newG ) ) return false;
}
for( var j = 0; j < keys.length; j++ ) {
var nk = newG + keys[j].substring( oldG.length );
ls.setItem( nk, ls.getItem( keys[j] ) ); ls.removeItem( keys[j] );
}
return keys.length > 0;
};
})( typeof globalThis !== 'undefined' ? globalThis : this );