diff --git a/PROJECT_CONTEXT.md b/PROJECT_CONTEXT.md index 785692e..5484f6f 100644 --- a/PROJECT_CONTEXT.md +++ b/PROJECT_CONTEXT.md @@ -4,437 +4,227 @@ MifKit — MapInfo data toolkit (desktop GUI + CLI). Renamed from MifMapXL in 1.1.0. -The notes below describe the original `mif-to-xlsx` feature, which remains the first converter. Newer converters (KML/KMZ ↔ MapInfo, GeoJSON, Shapefile, etc.) are added as additional entries in `src/core/converters/` under the same Converter contract — see `src/core/converters/types.js` and `registry.js`. - ## Purpose -This is a desktop app for converting MapInfo `.mif` + `.mid` pairs into: - -- `.xlsx` files with all original attribute fields -- optional `.csv` export -- an extra column `region_color_hex` -- optional row background fill in Excel using the region fill color -- optional skip for black fill (`#000000`) - -The app is intended for non-technical Windows users who should be able to run a normal GUI app instead of using Node.js scripts in a terminal. - ---- - -## Product goal - -The user wants a simple Windows executable with UI where they can: +A growing collection of converters for MapInfo `.mif/.mid` and adjacent geo formats (KML/KMZ, GeoJSON, Shapefile, Excel). Packaged as a desktop GUI (Electron) and — once the CLI ships — a terminal binary built on the same engine. -- choose a folder or specific files for processing -- recursively scan subfolders if needed -- generate Excel files from MapInfo data -- add a color column extracted from polygon style -- optionally fill Excel rows with that color -- skip black fill if configured -- optionally merge all outputs into one workbook -- optionally also export CSV +The target user is non-technical (Windows + MapInfo Pro), so the GUI is the primary front-end. The CLI is for power users, automation, and CI pipelines. -The user is pragmatic and wants a working tool, not a theoretical one. +The product mood is **ffmpeg for MapInfo data**: one engine, every conversion direction surfaces in both the GUI and CLI through the same contract, behavior is deterministic, output is correct enough to import into MapInfo Pro without manual fixing. ---- +## Architecture -## Current stack +### Three layers -- Electron -- Node.js -- exceljs -- electron-builder +1. **Core engine** — `src/core/`. Pure Node.js, no Electron dependency, testable in isolation. Each converter is one folder under `src/core/converters/` exporting a Converter object. +2. **Desktop shell** — `src/main/` (Electron main process, IPC, worker orchestration) + `src/renderer/` (HTML/JS UI). The renderer is thin and schema-driven — it does not know any converter-specific details, it just renders forms from each converter's declarative `options[]` schema and dispatches the user's selection back through IPC. +3. **CLI** — `bin/mifkit` is a thin wrapper over `src/cli/index.js` which dispatches `list` / `help` / `convert` commands through the same registry. Option flags are parsed against each converter's schema (booleans via `--key` / `--no-key`, everything else via `--key=value`). GUI and CLI are interchangeable entry points to the same core. ---- - -## High-level architecture +``` +bin/ + mifkit.js CLI entry — thin shebang over src/cli +src/ + main/ Electron shell + main.js · preload.js · worker.js + renderer/ Desktop UI + index.html · renderer.js · styles.css · i18n.js + cli/ CLI — same registry, different front-end + index.js runCli(argv): commands list / help / convert + parseArgs.js · coerceOptions.js · format.js + core/ + common/ Shared utilities + color.js KML AABBGGRR <-> MapInfo int <-> #RRGGBB + zip.js Minimal ZIP reader (zlib only) + converters/ + registry.js register / get / list / validateOptions + types.js JSDoc Converter contract + index.js Auto-registers all built-in converters + mif-to-xlsx/ MapInfo MIF/MID -> Excel/CSV + kml-to-mif/ KML/KMZ -> MapInfo MIF/MID + convert.js Legacy orchestration for mif-to-xlsx + (still used inside that converter; will + fold into the converter folder later) + mif.js · mid.js MapInfo MIF/MID parsers + excel.js · csv.js Output writers + files.js Folder scan, MIF/MID pairing + encoding.js Charset detection via iconv-lite + settings.js Settings persistence with v1 -> v2 migration +test/ + cli/ · core/ · integration/ node:test suite, runs on every PR + fixtures/ small MIF/MID/KML samples +``` -The project has 2 main parts: +### The Converter contract + +Every converter exports a plain object of this shape (full JSDoc lives in `src/core/converters/types.js`): + +```js +{ + id: 'kml-to-mif', // stable kebab-case + name: 'KML/KMZ → MapInfo MIF/MID', + description: '...', + inputs: { extensions: ['.kml', '.kmz'], type: 'file-or-folder' }, + outputs: { extensions: ['.mif', '.mid'], type: 'folder' }, + options: [ + { key: 'flat', type: 'boolean', default: false, label: '...', description: '...' }, + { key: 'charset', type: 'enum', values: ['WindowsCyrillic', 'Neutral'], default: 'WindowsCyrillic', label: '...' }, + // ... + ], + async run({ inputs, output, options }, ctx) { + // ctx.log(message) + // ctx.progress({ total, done, currentFile }) + // returns { outputs: string[], stats: { processed, skipped, errors: [{file, error}] } } + }, +} +``` -### 1. Desktop UI -Electron app with: -- main process -- preload bridge -- renderer UI +The registry validates this shape on `register()`. `validateOptions()` checks each option against its declared type/enum/range before the converter runs. -Responsibilities: -- choose input folder or files -- choose output folder -- edit settings -- start conversion -- show logs and results +### How the GUI talks to the engine -### 2. Conversion engine -Pure Node.js logic that: -- scans files -- matches `.mif` with corresponding `.mid` -- parses MIF metadata and geometry style -- parses MID records -- builds CSV/XLSX output -- applies Excel row fill -- skips black when requested +``` +renderer (renderer.js) + ├─ window.api.listConverters() → IPC converters:list → registry.list() + ├─ renders --output=${exampleFlags(converter)}`, + ) + + return lines.join('\n') +} + +function formatOption(option) { + switch (option.type) { + case 'boolean': + return ` --${option.key} / --no-${option.key} (boolean, default: ${option.default ?? false})` + case 'enum': + return ` --${option.key}= (enum: ${(option.values || []).join(' | ')}, default: ${option.default})` + case 'number': { + const range = (option.min !== undefined || option.max !== undefined) + ? `, range: ${option.min ?? '-∞'}..${option.max ?? '+∞'}` + : '' + return ` --${option.key}= (number, default: ${option.default}${range})` + } + default: + return ` --${option.key}= (string, default: ${option.default ? `"${option.default}"` : '(empty)'})` + } +} + +function exampleFlags(converter) { + const example = converter.options + .find((o) => o.type === 'boolean' && o.default === false) + return example ? ` --${example.key}` : '' +} + +function indent(text, prefix = ' ') { + return String(text || '') + .split('\n') + .map((line) => prefix + line) + .join('\n') +} + +module.exports = { + topHelp, + listConverters, + converterHelp, + RESERVED_FLAGS, +} diff --git a/src/cli/index.js b/src/cli/index.js new file mode 100644 index 0000000..fbc59c6 --- /dev/null +++ b/src/cli/index.js @@ -0,0 +1,151 @@ +const pkg = require('../../package.json') +const converters = require('../core/converters') +const { parseArgs } = require('./parseArgs') +const { coerceOptions } = require('./coerceOptions') +const { topHelp, listConverters, converterHelp } = require('./format') + +const RESERVED = new Set(['output', 'o', 'help', 'version']) + +/** + * Run the CLI with the given argv. Pure with respect to side effects: all + * output goes through the injected `out` / `err` writers, so tests can call + * this directly without spawning a subprocess. + * + * @param {string[]} argv process.argv.slice(2) + * @param {Object} [io] + * @param {(s: string) => void} [io.out] stdout writer + * @param {(s: string) => void} [io.err] stderr writer + * @returns {Promise} exit code + */ +async function runCli(argv, io = {}) { + const out = io.out || ((s) => process.stdout.write(`${s}\n`)) + const err = io.err || ((s) => process.stderr.write(`${s}\n`)) + + const { command, positional, flags } = parseArgs(argv) + + if (flags.version) { + out(pkg.version) + return 0 + } + + if (!command || flags.help) { + if (command === 'help') { + return runHelp(positional, out, err) + } + out(topHelp()) + return command ? 0 : 0 + } + + switch (command) { + case 'list': + return runList(out) + case 'help': + return runHelp(positional, out, err) + case 'convert': + return runConvert(positional, flags, out, err) + default: + err(`Unknown command: ${command}`) + err('') + err(topHelp()) + return 2 + } +} + +function runList(out) { + out(listConverters(converters.list())) + return 0 +} + +function runHelp(positional, out, err) { + const [id] = positional + if (!id) { + out(topHelp()) + return 0 + } + const converter = converters.get(id) + if (!converter) { + err(`Unknown converter: ${id}`) + err('') + err('Available:') + err(listConverters(converters.list())) + return 2 + } + out(converterHelp(converter)) + return 0 +} + +async function runConvert(positional, flags, out, err) { + const [converterId, ...inputs] = positional + if (!converterId) { + err('Missing converter id') + err('Usage: mifkit convert --output= [options]') + return 2 + } + const converter = converters.get(converterId) + if (!converter) { + err(`Unknown converter: ${converterId}`) + err('') + err('Available:') + err(listConverters(converters.list())) + return 2 + } + if (!inputs.length) { + err(`Missing input path(s) for ${converterId}`) + err(`Try: mifkit help ${converterId}`) + return 2 + } + + const output = flags.output || flags.o + if (!output || typeof output !== 'string') { + err('Missing --output=') + return 2 + } + + const { options: rawOptions, errors: coerceErrors } = coerceOptions( + [...converter.options], + flags, + RESERVED, + ) + if (coerceErrors.length) { + for (const e of coerceErrors) err(e) + return 2 + } + + const validated = converters.validateOptions(converter, rawOptions) + if (validated.errors.length) { + for (const e of validated.errors) err(e) + return 2 + } + + const ctx = { + log: (msg) => err(msg), + progress: () => {}, + } + + try { + const result = await converter.run( + { inputs, output, options: validated.merged }, + ctx, + ) + + const stats = result.stats || { processed: 0, skipped: 0, errors: [] } + err(`Processed: ${stats.processed}, Skipped: ${stats.skipped}`) + + for (const o of result.outputs || []) { + out(o) + } + + if (stats.errors && stats.errors.length) { + for (const e of stats.errors) { + err(` ${e.file}: ${e.error}`) + } + return 1 + } + return 0 + } catch (error) { + err(`FATAL: ${error && error.message ? error.message : String(error)}`) + return 1 + } +} + +module.exports = { runCli } diff --git a/src/cli/parseArgs.js b/src/cli/parseArgs.js new file mode 100644 index 0000000..473a49f --- /dev/null +++ b/src/cli/parseArgs.js @@ -0,0 +1,57 @@ +/** + * Minimal CLI argument parser, schema-agnostic. + * + * Conventions: + * --key=value key set to "value" + * --key key set to true (boolean shorthand) + * --no-key key set to false + * -h, --help parsed as { help: true } + * -v, --version parsed as { version: true } + * + * Anything not starting with "-" becomes positional. The first positional is + * exposed as `command`; the rest stay in `positional`. + * + * @param {string[]} argv + * @returns {{ command: string, positional: string[], flags: Object }} + */ +function parseArgs(argv) { + const positional = [] + const flags = {} + const args = Array.isArray(argv) ? argv.slice() : [] + + for (const arg of args) { + if (arg === undefined || arg === null) continue + const token = String(arg) + + if (token === '-h' || token === '--help') { + flags.help = true + continue + } + if (token === '-v' || token === '--version') { + flags.version = true + continue + } + if (token.startsWith('--')) { + const body = token.slice(2) + const eq = body.indexOf('=') + if (eq === -1) { + if (body.startsWith('no-')) { + flags[body.slice(3)] = false + } else { + flags[body] = true + } + } else { + const key = body.slice(0, eq) + const value = body.slice(eq + 1) + flags[key] = value + } + continue + } + positional.push(token) + } + + const [command = '', ...rest] = positional + return { command, positional: rest, flags } +} + +module.exports = { parseArgs } diff --git a/src/core/common/color.js b/src/core/common/color.js new file mode 100644 index 0000000..4a15da2 --- /dev/null +++ b/src/core/common/color.js @@ -0,0 +1,65 @@ +/** + * Color helpers shared by all converters. + * + * KML stores colors as AABBGGRR hex (8 chars). MapInfo MIF stores them as a + * decimal integer formed as 0xRRGGBB. Excel uses #RRGGBB. Keep all conversions + * in one place so future converters reuse the same wiring. + */ + +const HEX_RE = /^[0-9a-fA-F]+$/ + +/** + * Parse a KML `` value (AABBGGRR or BBGGRR) to MapInfo decimal int. + * Returns `fallback` if the input is missing or malformed. + */ +function kmlColorToMapInfo(kmlColor, fallback) { + if (!kmlColor || typeof kmlColor !== 'string') { + return fallback + } + + const value = kmlColor.trim().toLowerCase() + + if (!HEX_RE.test(value)) { + return fallback + } + + let bb + let gg + let rr + + if (value.length === 8) { + bb = value.slice(2, 4) + gg = value.slice(4, 6) + rr = value.slice(6, 8) + } else if (value.length === 6) { + bb = value.slice(0, 2) + gg = value.slice(2, 4) + rr = value.slice(4, 6) + } else { + return fallback + } + + return parseInt(rr + gg + bb, 16) +} + +/** + * Format a MapInfo color int as `#RRGGBB`. + */ +function mapInfoColorToHex(value) { + const safe = Number.isFinite(value) ? value & 0xffffff : 0 + return `#${safe.toString(16).toUpperCase().padStart(6, '0')}` +} + +/** + * Parse a KML `` value to `#RRGGBB`. Returns null on malformed input. + */ +function kmlColorToHex(kmlColor) { + const value = kmlColorToMapInfo(kmlColor, null) + return value === null ? null : mapInfoColorToHex(value) +} + +module.exports = { + kmlColorToMapInfo, + kmlColorToHex, + mapInfoColorToHex, +} diff --git a/src/core/common/zip.js b/src/core/common/zip.js new file mode 100644 index 0000000..a2a2ccb --- /dev/null +++ b/src/core/common/zip.js @@ -0,0 +1,126 @@ +const zlib = require('zlib') + +const SIG_LOCAL = 0x04034b50 +const SIG_CDIR = 0x02014b50 +const SIG_EOCD = 0x06054b50 +const SIG_EOCD64 = 0x06064b50 + +const MAX_EOCD_COMMENT = 0xffff +const EOCD_FIXED_SIZE = 22 + +/** + * Read a ZIP archive into an in-memory directory of entries. + * + * Supports stored (method 0) and deflate (method 8) compression — enough for + * KMZ archives and ZIP-bundled Shapefiles. ZIP64 is detected and treated as + * unsupported (most KMZ/SHP bundles are well under 4 GB). + * + * @param {Buffer} buffer + * @returns {{ entries: Array<{ name: string, size: number, extract: () => Buffer }> }} + */ +function readZip(buffer) { + if (!Buffer.isBuffer(buffer)) { + throw new Error('readZip expects a Buffer') + } + + const eocd = findEOCD(buffer) + + if (eocd.method === 'zip64') { + throw new Error('ZIP64 archives are not supported') + } + + const entries = readCentralDirectory(buffer, eocd.cdOffset, eocd.totalEntries) + return { + entries: entries.map((entry) => ({ + name: entry.name, + size: entry.uncompressedSize, + extract: () => extractEntry(buffer, entry), + })), + } +} + +function findEOCD(buffer) { + const minOffset = Math.max(0, buffer.length - EOCD_FIXED_SIZE - MAX_EOCD_COMMENT) + + for (let i = buffer.length - EOCD_FIXED_SIZE; i >= minOffset; i -= 1) { + if (buffer.readUInt32LE(i) === SIG_EOCD) { + const totalEntries = buffer.readUInt16LE(i + 10) + const cdOffset = buffer.readUInt32LE(i + 16) + + if (totalEntries === 0xffff || cdOffset === 0xffffffff) { + return { method: 'zip64' } + } + + return { method: 'standard', totalEntries, cdOffset } + } + + if (buffer.readUInt32LE(i) === SIG_EOCD64) { + return { method: 'zip64' } + } + } + + throw new Error('End of central directory not found — not a ZIP archive') +} + +function readCentralDirectory(buffer, cdOffset, totalEntries) { + const entries = [] + let cursor = cdOffset + + for (let i = 0; i < totalEntries; i += 1) { + if (buffer.readUInt32LE(cursor) !== SIG_CDIR) { + throw new Error(`Central directory record corrupted at offset ${cursor}`) + } + + const compressionMethod = buffer.readUInt16LE(cursor + 10) + const compressedSize = buffer.readUInt32LE(cursor + 20) + const uncompressedSize = buffer.readUInt32LE(cursor + 24) + const nameLength = buffer.readUInt16LE(cursor + 28) + const extraLength = buffer.readUInt16LE(cursor + 30) + const commentLength = buffer.readUInt16LE(cursor + 32) + const localHeaderOffset = buffer.readUInt32LE(cursor + 42) + const name = buffer.slice(cursor + 46, cursor + 46 + nameLength).toString('utf8') + + if (compressedSize === 0xffffffff || uncompressedSize === 0xffffffff || localHeaderOffset === 0xffffffff) { + throw new Error(`ZIP64 entry "${name}" is not supported`) + } + + entries.push({ + name, + compressionMethod, + compressedSize, + uncompressedSize, + localHeaderOffset, + }) + + cursor += 46 + nameLength + extraLength + commentLength + } + + return entries +} + +function extractEntry(buffer, entry) { + const localOffset = entry.localHeaderOffset + + if (buffer.readUInt32LE(localOffset) !== SIG_LOCAL) { + throw new Error(`Local file header missing for ${entry.name}`) + } + + const nameLength = buffer.readUInt16LE(localOffset + 26) + const extraLength = buffer.readUInt16LE(localOffset + 28) + const dataOffset = localOffset + 30 + nameLength + extraLength + const compressed = buffer.slice(dataOffset, dataOffset + entry.compressedSize) + + if (entry.compressionMethod === 0) { + return Buffer.from(compressed) + } + + if (entry.compressionMethod === 8) { + return zlib.inflateRawSync(compressed) + } + + throw new Error(`Unsupported compression method ${entry.compressionMethod} for ${entry.name}`) +} + +module.exports = { + readZip, +} diff --git a/src/core/converters/index.js b/src/core/converters/index.js index f9c7826..ed08f2b 100644 --- a/src/core/converters/index.js +++ b/src/core/converters/index.js @@ -1,5 +1,6 @@ const registry = require('./registry') const mifToXlsx = require('./mif-to-xlsx') +const kmlToMif = require('./kml-to-mif') let initialized = false @@ -8,6 +9,7 @@ function ensureInitialized() { return } registry.register(mifToXlsx) + registry.register(kmlToMif) initialized = true } diff --git a/src/core/converters/kml-to-mif/index.js b/src/core/converters/kml-to-mif/index.js new file mode 100644 index 0000000..4a25207 --- /dev/null +++ b/src/core/converters/kml-to-mif/index.js @@ -0,0 +1,162 @@ +const fs = require('fs') +const path = require('path') + +const { readZip } = require('../../common/zip') +const { parseKml } = require('./parseKml') +const { writeMifFromKml } = require('./writeMif') + +/** + * kml-to-mif — KML/KMZ to MapInfo MIF/MID. + * + * Preserves per-feature colors (Pen/Brush/Symbol) by resolving each + * Placemark's styleUrl through StyleMap and Style chains. Saves the KML + * Folder hierarchy either as nested directories (default) or as one + * directory with prefixed filenames (`flat`). + * + * @type {import('../types').Converter} + */ +const converter = { + id: 'kml-to-mif', + name: 'KML/KMZ → MapInfo MIF/MID', + description: 'Convert Google Earth KML/KMZ to MapInfo Interchange (MIF/MID). Preserves per-feature colors from + + + + normal + #redOutline + + + highlight + #blueFilled + + + + Layer A + + Red Region + outlined polygon + #redMap + + + + + 10,20,0 11,20,0 11,21,0 10,21,0 10,20,0 + + + + + + + Blue Region + #blueFilled + + + + + 30,40,0 31,40,0 31,41,0 30,41,0 30,40,0 + + + + + + + Sublayer + + Track + #redMap + + + 50,60,0 51,60,0 52,61,0 + + + + + Marker + #blueFilled + + 70,80,0 + + + + + + diff --git a/test/integration/kml-to-mif.test.js b/test/integration/kml-to-mif.test.js new file mode 100644 index 0000000..4793e48 --- /dev/null +++ b/test/integration/kml-to-mif.test.js @@ -0,0 +1,213 @@ +const test = require('node:test') +const assert = require('node:assert') +const fs = require('node:fs') +const path = require('node:path') +const os = require('node:os') +const zlib = require('node:zlib') +const iconv = require('iconv-lite') + +const converters = require('../../src/core/converters') + +const FIXTURES = path.join(__dirname, '..', 'fixtures') +const SAMPLE_KML = path.join(FIXTURES, 'sample.kml') + +function mkTmp() { + return fs.mkdtempSync(path.join(os.tmpdir(), 'mifkit-kml-')) +} + +function readCp1251(filePath) { + return iconv.decode(fs.readFileSync(filePath), 'windows-1251') +} + +test('kml-to-mif converter is registered with the expected option schema', () => { + const c = converters.get('kml-to-mif') + assert.ok(c) + assert.deepStrictEqual(c.inputs.extensions, ['.kml', '.kmz']) + assert.deepStrictEqual(c.outputs.extensions, ['.mif', '.mid']) + const optionKeys = c.options.map((o) => o.key).sort() + assert.deepStrictEqual(optionKeys, ['charset', 'flat', 'recursive']) +}) + +test('kml-to-mif produces MIF/MID with resolved styles and folder hierarchy', async () => { + const outDir = mkTmp() + const c = converters.get('kml-to-mif') + const result = await c.run( + { + inputs: [SAMPLE_KML], + output: outDir, + options: converters.applyDefaults(c, {}), + }, + { log: () => {}, progress: () => {} }, + ) + + assert.strictEqual(result.stats.processed, 1) + assert.strictEqual(result.stats.skipped, 0) + assert.deepStrictEqual(result.stats.errors, []) + + const layerAMif = path.join(outDir, 'Layer_A.mif') + const layerAMid = path.join(outDir, 'Layer_A.mid') + const subMif = path.join(outDir, 'Layer_A', 'Sublayer.mif') + const subMid = path.join(outDir, 'Layer_A', 'Sublayer.mid') + + assert.ok(fs.existsSync(layerAMif), 'Layer_A.mif should exist') + assert.ok(fs.existsSync(layerAMid)) + assert.ok(fs.existsSync(subMif), 'Layer_A/Sublayer.mif should exist') + assert.ok(fs.existsSync(subMid)) + + const layerAMifText = readCp1251(layerAMif) + // Red region (outlined, no fill): Pen color from ff0000aa -> 0xAA0000 (11141120), Brush pattern 1 + assert.match(layerAMifText, /Pen \(1,2,11141120\)/) + assert.match(layerAMifText, /Brush \(1,11141120,16777215\)/) + // Blue region (filled): Brush pattern 2 with poly color from ffff0000 -> 0x0000FF (255) + assert.match(layerAMifText, /Pen \(1,2,0\)/) + assert.match(layerAMifText, /Brush \(2,255,16777215\)/) + // CoordSys + Columns header present + assert.match(layerAMifText, /CoordSys Earth Projection 1, 104/) + assert.match(layerAMifText, /Columns 4/) + + const layerAMidText = readCp1251(layerAMid) + assert.match(layerAMidText, /"Red Region","outlined polygon","redOutline","Layer A"/) + assert.match(layerAMidText, /"Blue Region","","blueFilled","Layer A"/) + + const subMifText = readCp1251(subMif) + // Line with width 2 from redOutline style + assert.match(subMifText, /Pline 3/) + assert.match(subMifText, /Pen \(2,2,11141120\)/) + // Point with icon color FFFF00 -> 16776960 + assert.match(subMifText, /Point 70 80/) + assert.match(subMifText, /Symbol \(35,16776960,12\)/) + + const subMidText = readCp1251(subMid) + assert.match(subMidText, /"Track","","redOutline","Layer A \/ Sublayer"/) + assert.match(subMidText, /"Marker","","blueFilled","Layer A \/ Sublayer"/) +}) + +test('kml-to-mif with flat=true produces a single directory with prefixed names', async () => { + const outDir = mkTmp() + const c = converters.get('kml-to-mif') + await c.run( + { + inputs: [SAMPLE_KML], + output: outDir, + options: converters.applyDefaults(c, { flat: true }), + }, + { log: () => {}, progress: () => {} }, + ) + + const files = fs.readdirSync(outDir).sort() + assert.deepStrictEqual(files, [ + 'Layer_A.mid', + 'Layer_A.mif', + 'Layer_A__Sublayer.mid', + 'Layer_A__Sublayer.mif', + ]) +}) + +test('kml-to-mif handles KMZ archives', async () => { + // Build a tiny KMZ from the sample KML + const kmlBuffer = fs.readFileSync(SAMPLE_KML) + const kmzBuffer = buildKmz('doc.kml', kmlBuffer) + const inputDir = mkTmp() + const kmzPath = path.join(inputDir, 'sample.kmz') + fs.writeFileSync(kmzPath, kmzBuffer) + + const outDir = mkTmp() + const c = converters.get('kml-to-mif') + const result = await c.run( + { + inputs: [kmzPath], + output: outDir, + options: converters.applyDefaults(c, {}), + }, + { log: () => {}, progress: () => {} }, + ) + + assert.strictEqual(result.stats.processed, 1) + assert.ok(fs.existsSync(path.join(outDir, 'Layer_A.mif'))) +}) + +test('kml-to-mif charset=Neutral writes UTF-8', async () => { + const outDir = mkTmp() + const c = converters.get('kml-to-mif') + await c.run( + { + inputs: [SAMPLE_KML], + output: outDir, + options: converters.applyDefaults(c, { charset: 'Neutral' }), + }, + { log: () => {}, progress: () => {} }, + ) + + const mifText = fs.readFileSync(path.join(outDir, 'Layer_A.mif'), 'utf8') + assert.match(mifText, /Charset "Neutral"/) +}) + +// --- KMZ builder for tests ------------------------------------------------- + +const CRC_TABLE = (() => { + const table = new Uint32Array(256) + for (let n = 0; n < 256; n += 1) { + let c = n + for (let k = 0; k < 8; k += 1) { + c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1 + } + table[n] = c + } + return table +})() + +function crc32(buf) { + let crc = 0xffffffff + for (let i = 0; i < buf.length; i += 1) { + crc = (crc >>> 8) ^ CRC_TABLE[(crc ^ buf[i]) & 0xff] + } + return (crc ^ 0xffffffff) >>> 0 +} + +function buildKmz(name, content) { + const nameBuf = Buffer.from(name, 'utf8') + const data = Buffer.isBuffer(content) ? content : Buffer.from(content, 'utf8') + const compressed = zlib.deflateRawSync(data) + const crc = crc32(data) + + const local = Buffer.alloc(30) + local.writeUInt32LE(0x04034b50, 0) + local.writeUInt16LE(20, 4) + local.writeUInt16LE(0, 6) + local.writeUInt16LE(8, 8) + local.writeUInt16LE(0, 10) + local.writeUInt16LE(0, 12) + local.writeUInt32LE(crc, 14) + local.writeUInt32LE(compressed.length, 18) + local.writeUInt32LE(data.length, 22) + local.writeUInt16LE(nameBuf.length, 26) + local.writeUInt16LE(0, 28) + + const cd = Buffer.alloc(46) + cd.writeUInt32LE(0x02014b50, 0) + cd.writeUInt16LE(20, 4) + cd.writeUInt16LE(20, 6) + cd.writeUInt16LE(0, 8) + cd.writeUInt16LE(8, 10) + cd.writeUInt16LE(0, 12) + cd.writeUInt16LE(0, 14) + cd.writeUInt32LE(crc, 16) + cd.writeUInt32LE(compressed.length, 20) + cd.writeUInt32LE(data.length, 24) + cd.writeUInt16LE(nameBuf.length, 28) + + const cdOffset = local.length + nameBuf.length + compressed.length + const cdSize = cd.length + nameBuf.length + + const eocd = Buffer.alloc(22) + eocd.writeUInt32LE(0x06054b50, 0) + eocd.writeUInt16LE(0, 4) + eocd.writeUInt16LE(0, 6) + eocd.writeUInt16LE(1, 8) + eocd.writeUInt16LE(1, 10) + eocd.writeUInt32LE(cdSize, 12) + eocd.writeUInt32LE(cdOffset, 16) + eocd.writeUInt16LE(0, 20) + + return Buffer.concat([local, nameBuf, compressed, cd, nameBuf, eocd]) +}