diff --git a/package-lock.json b/package-lock.json index b93d133..c4a0ef0 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,15 +1,16 @@ { - "name": "mifmapxl", - "version": "1.0.4", + "name": "mifkit", + "version": "1.1.0", "lockfileVersion": 3, "requires": true, "packages": { "": { - "name": "mifmapxl", - "version": "1.0.4", + "name": "mifkit", + "version": "1.1.0", "license": "MIT", "dependencies": { "exceljs": "^4.4.0", + "fast-xml-parser": "^5.8.0", "iconv-lite": "^0.6.3" }, "devDependencies": { @@ -596,6 +597,18 @@ "node": ">= 10.0.0" } }, + "node_modules/@nodable/entities": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/@nodable/entities/-/entities-2.1.0.tgz", + "integrity": "sha512-nyT7T3nbMyBI/lvr6L5TyWbFJAI9FTgVRakNoBqCD+PmID8DzFrrNdLLtHMwMszOtqZa8PAOV24ZqDnQrhQINA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/nodable" + } + ], + "license": "MIT" + }, "node_modules/@sindresorhus/is": { "version": "4.6.0", "resolved": "https://registry.npmjs.org/@sindresorhus/is/-/is-4.6.0.tgz", @@ -2394,6 +2407,44 @@ "dev": true, "license": "MIT" }, + "node_modules/fast-xml-builder": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/fast-xml-builder/-/fast-xml-builder-1.2.0.tgz", + "integrity": "sha512-00aAWieqff+ZJhsXA4g1g7M8k+7AYoMUUHF+/zFb5U6Uv/P0Vl4QZo84/IcufzYalLuEj9928bXN9PbbFzMF0Q==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT", + "dependencies": { + "path-expression-matcher": "^1.5.0", + "xml-naming": "^0.1.0" + } + }, + "node_modules/fast-xml-parser": { + "version": "5.8.0", + "resolved": "https://registry.npmjs.org/fast-xml-parser/-/fast-xml-parser-5.8.0.tgz", + "integrity": "sha512-6bIM7fsJxeo3uXv7OncQYsBAMPJ7V16Slahl/6M98C/i2q+vB1+4a0MtrvYwDFEUrwDSbAmeLDRXsOBwrL7yAg==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT", + "dependencies": { + "@nodable/entities": "^2.1.0", + "fast-xml-builder": "^1.2.0", + "path-expression-matcher": "^1.5.0", + "strnum": "^2.3.0", + "xml-naming": "^0.1.0" + }, + "bin": { + "fxparser": "src/cli/cli.js" + } + }, "node_modules/fd-slicer": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/fd-slicer/-/fd-slicer-1.1.0.tgz", @@ -3641,6 +3692,21 @@ "integrity": "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==", "license": "(MIT AND Zlib)" }, + "node_modules/path-expression-matcher": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/path-expression-matcher/-/path-expression-matcher-1.5.0.tgz", + "integrity": "sha512-cbrerZV+6rvdQrrD+iGMcZFEiiSrbv9Tfdkvnusy6y0x0GKBXREFg/Y65GhIfm0tnLntThhzCnfKwp1WRjeCyQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, "node_modules/path-is-absolute": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz", @@ -4245,6 +4311,18 @@ "node": ">=8" } }, + "node_modules/strnum": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/strnum/-/strnum-2.3.0.tgz", + "integrity": "sha512-ums3KNd42PGyx5xaoVTO1mjU1bH3NpY4vsrVlnv9PNGqQj8wd7rJ6nEypLrJ7z5vxK5RP0yMLo6J/Gsm62DI5Q==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT" + }, "node_modules/sumchecker": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/sumchecker/-/sumchecker-3.0.1.tgz", @@ -4630,6 +4708,21 @@ "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", "license": "ISC" }, + "node_modules/xml-naming": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/xml-naming/-/xml-naming-0.1.0.tgz", + "integrity": "sha512-k8KO9hrMyNk6tUWqUfkTEZbezRRpONVOzUTnc97VnCvyj6Tf9lyUR9EDAIeiVLv56jsMcoXEwjW8Kv5yPY52lw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT", + "engines": { + "node": ">=16.0.0" + } + }, "node_modules/xmlbuilder": { "version": "15.1.1", "resolved": "https://registry.npmjs.org/xmlbuilder/-/xmlbuilder-15.1.1.tgz", diff --git a/package.json b/package.json index 6064ed7..61997e1 100644 --- a/package.json +++ b/package.json @@ -18,6 +18,7 @@ }, "dependencies": { "exceljs": "^4.4.0", + "fast-xml-parser": "^5.8.0", "iconv-lite": "^0.6.3" }, "devDependencies": { 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]) +}