#!/usr/bin/env node /** * 离线地图资源下载与本地化样式生成脚本 * --------------------------------------------------------------------------- * 把项目当前使用的外网地图资源(OpenFreeMap)一次性抓取到本地 public/map 下, * 并生成一份所有地址都指向本地的 style.json,使内网 / 离线环境也能正常显示地图, * 且样式与线上(dev / prod)完全一致。 * * 抓取内容: * 1. 矢量瓦片 tiles/{z}/{x}/{y}.pbf (源自 tiles.openfreemap.org/planet) * 2. 字体字形 fonts/{fontstack}/{range}.pbf (glyphs,地图注记文字 / 点位数字依赖) * !!! 默认抓全部 256 个区间,不要裁剪 !!! * vjmap 解析字形时执行 `for (const g of parsed.glyphs)`,只要某个区间缺失、 * 请求拿到空数据,glyphs 就是 undefined,直接抛 "glyphs is not iterable"。 * 3. 图标雪碧 sprites/ofm.json|png|@2x.png (地图 POI 图标) * 4. 生成样式 shanxi.offline.json (源样式 shanxi.map.json 的本地化版本) * * 用法: * node scripts/fetch-offline-map.mjs # 默认:陕西范围 z0-z9(体积最小) * node scripts/fetch-offline-map.mjs --maxzoom=11 # 更细(+约 53MB),--maxzoom=12 再 +约 70MB * node scripts/fetch-offline-map.mjs --skip-existing # 断点续传,已存在的文件跳过 * node scripts/fetch-offline-map.mjs --bbox=105,31,112,40 # 自定义范围 lonMin,latMin,lonMax,latMax * node scripts/fetch-offline-map.mjs --base=/map/ # 部署在子路径下时改写资源前缀 * node scripts/fetch-offline-map.mjs --fonts="Noto Sans Regular" # 只保留一个字体栈(省约 78MB) * node scripts/fetch-offline-map.mjs --no-strip # 不精简瓦片(保留多语言 name 字段) * node scripts/fetch-offline-map.mjs --glyphs=all # 全量字形(最保险,体积大) * * 网络要求:只在能上网的机器上执行一次;执行完把 public/map 整个目录拷到内网即可。 */ import { mkdir, readFile, readdir, rm, writeFile, stat } from 'node:fs/promises'; import path from 'node:path'; // -------------------------------------------------------------------------- // 参数 // -------------------------------------------------------------------------- const ROOT = process.cwd(); const args = {}; for (const rawArg of process.argv.slice(2)) { const m = /^--([^=]+)(?:=(.*))?$/.exec(rawArg); if (m) args[m[1]] = m[2] === undefined ? true : m[2]; } // 陕西省大致范围,默认外扩 0.7 度,避免地图边缘缺瓦片 const PADDING = Number(args.padding ?? 0.7); const [bLon, bLat, tLon, tLat] = String(args.bbox ?? '105.4,31.6,111.3,39.7') .split(',') .map(Number); // lonMin, latMin, lonMax, latMax const BBOX = [bLon - PADDING, bLat - PADDING, tLon + PADDING, tLat + PADDING]; const MINZOOM = Number(args.minzoom ?? 0); const MAXZOOM = Number(args.maxzoom ?? 9); const OUT_DIR = path.resolve(ROOT, args.out ?? 'public/map'); const BASE = String(args.base ?? '/map/'); // 源样式:保留 OpenFreeMap 外网地址的原始样式(生成器输入) const STYLE_SRC = path.resolve(ROOT, args.style ?? 'scripts/map/shanxi.map.source.json'); // 产物:直接覆盖前端引用的样式文件,这样前端代码一行都不用改 const STYLE_OUT = path.resolve(ROOT, args['style-out'] ?? 'public/js/shanxi.map.json'); const CONCURRENCY = Number(args.concurrency ?? 12); const SKIP_EXISTING = Boolean(args['skip-existing'] || args.resume); // used=按瓦片文字推导(默认,体积最小) | all-cjk=全量 CJK | all=全部 256 个区间(最保险) const GLYPH_MODE = String(args.glyphs ?? 'used'); // 精简瓦片:删除样式用不到的 name:* 字段、未被引用的键值、样式不引用的图层(约省 28%) const STRIP_TILES = !args['no-strip']; const KEEP_NAME_KEYS = new Set( String(args['keep-names'] ?? 'name,name:latin,name:nonlatin') .split(',') .map((s) => s.trim()) .filter(Boolean), ); // vjmap(mapbox-gl 1.x) 的 normalizeSpriteURL 会无条件 parseUrl(), // 只接受 "协议://..." 形式的绝对地址,相对路径会抛 "Unable to parse URL object"。 // 因此默认不写 sprite 字段(地图照常渲染,只是 POI 图标不显示); // 需要图标时用 --sprite-base=http://your-host/map/sprites/ofm 传入绝对地址。 const SPRITE_BASE = args['sprite-base'] ? String(args['sprite-base']) : ''; const UA = 'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0 Safari/537.36'; const VECTOR_SOURCE_ID = 'openmaptiles'; // 样式里声明了但没有图层使用的源,本地化时直接移除 const UNUSED_SOURCES = ['ne2_shaded']; // -------------------------------------------------------------------------- // 工具函数 // -------------------------------------------------------------------------- const log = (...a) => console.log(...a); const fmtMB = (bytes) => (bytes / 1048576).toFixed(1) + 'MB'; function lonLatToTile(lon, lat, z) { const n = 2 ** z; const latClamped = Math.min(Math.max(lat, -85.05112878), 85.05112878); const x = Math.floor(((lon + 180) / 360) * n); const rad = (latClamped * Math.PI) / 180; const y = Math.floor(((1 - Math.log(Math.tan(rad) + 1 / Math.cos(rad)) / Math.PI) / 2) * n); return [Math.min(Math.max(x, 0), n - 1), Math.min(Math.max(y, 0), n - 1)]; } /** * 分层范围:低层级看到的范围远大于省界(全屏地图在 z6 时横向能看到 40 多度经度), * 若只下载省内瓦片,屏幕上会出现大片空白。因此按层级给不同范围: * z0-z6 中国范围(张数很少,成本低) * z7-z8 陕西周边 * z9+ 陕西省内 * 可用 --tiers="0-6:73,3,136,54;7-8:98,24,122,45;9-11:104.7,30.9,112,40.4" 覆盖。 */ function buildTiers() { const custom = args.tiers ? String(args.tiers) : null; const parse = (s) => s.split(',').map(Number); if (custom) { return custom .split(';') .filter(Boolean) .map((seg) => { const [zooms, bbox] = seg.split(':'); const [z0, z1] = zooms.split('-').map(Number); return { min: z0, max: z1, bbox: parse(bbox) }; }); } return [ // z0-z6:中国范围(首页默认 z6) { min: 0, max: Math.min(6, MAXZOOM), bbox: parse(String(args['china-bbox'] ?? '73,3,136,54')) }, // z7-z8:陕西周边 { min: 7, max: Math.min(8, MAXZOOM), bbox: parse(String(args['region-bbox'] ?? '98,24,122,45')) }, // z9+:陕西省内 { min: 9, max: MAXZOOM, bbox: BBOX }, ].filter((t) => t.min <= t.max); } const TIERS = buildTiers(); function bboxForZoom(z) { const tier = TIERS.find((t) => z >= t.min && z <= t.max); return tier ? tier.bbox : BBOX; } function tileRangeForZoom(z) { const [lonMin, latMin, lonMax, latMax] = bboxForZoom(z); const [x0, y0] = lonLatToTile(lonMin, latMax, z); const [x1, y1] = lonLatToTile(lonMax, latMin, z); return { x0, x1, y0, y1, count: (x1 - x0 + 1) * (y1 - y0 + 1), bbox: bboxForZoom(z) }; } async function ensureDir(p) { await mkdir(p, { recursive: true }); } async function fileSize(p) { try { return (await stat(p)).size; } catch { return -1; } } const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); async function fetchBuffer(url, retries = 4) { let lastErr; for (let i = 0; i <= retries; i++) { try { const res = await fetch(url, { headers: { 'User-Agent': UA } }); if (res.status === 404) return { notFound: true, buffer: Buffer.alloc(0) }; if (!res.ok) throw new Error('HTTP ' + res.status); const ab = await res.arrayBuffer(); return { notFound: false, buffer: Buffer.from(ab) }; } catch (e) { lastErr = e; if (i < retries) await sleep(300 * (i + 1) * (i + 1)); } } throw new Error('下载失败 ' + url + ' : ' + (lastErr && lastErr.message)); } /** 简单并发池 */ async function pool(items, limit, worker, onProgress) { let cursor = 0; let done = 0; const results = new Array(items.length); const runners = Array.from({ length: Math.min(limit, items.length) }, async () => { while (cursor < items.length) { const idx = cursor++; results[idx] = await worker(items[idx], idx); done += 1; if (onProgress && (done % 100 === 0 || done === items.length)) onProgress(done, items.length); } }); await Promise.all(runners); return results; } // -------------------------------------------------------------------------- // 矢量瓦片精简(protobuf / MVT 原地重写,无第三方依赖) // - 删除样式用不到的 name:xx 字段(本样式只用 name:latin / name:nonlatin / ref) // - 删除因此不再被任何要素引用的 value,以及这些 key // - 删除样式没有引用的 source-layer(如 mountain_peak) // 对同一文件重复执行是幂等的。 // -------------------------------------------------------------------------- function pbReadVarint(buf, i) { let v = 0; let sh = 0; let c; do { if (i >= buf.length) throw new Error('truncated varint'); c = buf[i++]; v += (c & 0x7f) * Math.pow(2, sh); sh += 7; if (sh > 63) throw new Error('varint too long'); } while (c & 0x80); return [v, i]; } function pbWriteVarint(out, v) { while (v > 0x7f) { out.push((v & 0x7f) | 0x80); v = Math.floor(v / 128); } out.push(v & 0x7f); } function pbPush(out, bytes) { for (let i = 0; i < bytes.length; i++) out.push(bytes[i]); } function pbWriteBytes(out, field, bytes) { pbWriteVarint(out, field * 8 + 2); pbWriteVarint(out, bytes.length); pbPush(out, bytes); } function* pbFields(buf) { let i = 0; while (i < buf.length) { let tag; [tag, i] = pbReadVarint(buf, i); const field = Math.floor(tag / 8); const wire = tag % 8; if (wire === 0) { let v; [v, i] = pbReadVarint(buf, i); yield { field, wire, value: v }; } else if (wire === 2) { let len; [len, i] = pbReadVarint(buf, i); if (i + len > buf.length) throw new Error('length overflow'); yield { field, wire, value: buf.subarray(i, i + len) }; i += len; } else if (wire === 5) { yield { field, wire, value: buf.subarray(i, i + 4) }; i += 4; } else if (wire === 1) { yield { field, wire, value: buf.subarray(i, i + 8) }; i += 8; } else { throw new Error('unsupported wire type ' + wire); } } } function encodeFeature(feat, keyRemap, valRemap) { const out = []; if (feat.id != null) { pbWriteVarint(out, 1 * 8 + 0); pbWriteVarint(out, feat.id); } const tags = []; for (let i = 0; i + 1 < feat.tags.length; i += 2) { const k = keyRemap.get(feat.tags[i]); if (k === undefined) continue; tags.push(k, valRemap.get(feat.tags[i + 1])); } if (tags.length) { const tb = []; tags.forEach((v) => pbWriteVarint(tb, v)); pbWriteBytes(out, 2, tb); } if (feat.type != null) { pbWriteVarint(out, 3 * 8 + 0); pbWriteVarint(out, feat.type); } if (feat.geom) pbWriteBytes(out, 4, feat.geom); return Buffer.from(out); } function stripLayer(buf, keepLayers, stats) { let name = null; let extent = 4096; let version = 2; const keys = []; const values = []; const features = []; for (const f of pbFields(buf)) { if (f.field === 1 && f.wire === 2) name = f.value; else if (f.field === 2 && f.wire === 2) features.push(f.value); else if (f.field === 3 && f.wire === 2) keys.push(f.value); else if (f.field === 4 && f.wire === 2) values.push(f.value); else if (f.field === 5 && f.wire === 0) extent = f.value; else if (f.field === 15 && f.wire === 0) version = f.value; } const layerName = name ? name.toString('utf8') : ''; if (keepLayers && keepLayers.size && !keepLayers.has(layerName)) { stats.droppedLayers += 1; return null; } const keyRemap = new Map(); const keptKeys = []; keys.forEach((kb, idx) => { const k = kb.toString('utf8'); // OpenMapTiles 里多语言字段有两套命名:name:xx(冒号)和 name_xx(下划线,如 name_en/name_de/name_int) const isName = k.startsWith('name:') || /^name_[a-z]{2,3}$/.test(k); if (!isName || KEEP_NAME_KEYS.has(k)) { keyRemap.set(idx, keptKeys.length); keptKeys.push(idx); } else { stats.droppedKeys += 1; } }); const parsed = []; const usedValues = new Set(); for (const fb of features) { const feat = { id: null, type: null, geom: null, tags: [] }; for (const f of pbFields(fb)) { if (f.field === 1 && f.wire === 0) feat.id = f.value; else if (f.field === 3 && f.wire === 0) feat.type = f.value; else if (f.field === 4 && f.wire === 2) feat.geom = f.value; else if (f.field === 2 && f.wire === 2) { let i = 0; while (i < f.value.length) { let v; [v, i] = pbReadVarint(f.value, i); feat.tags.push(v); } } } for (let i = 0; i + 1 < feat.tags.length; i += 2) { if (keyRemap.has(feat.tags[i])) usedValues.add(feat.tags[i + 1]); } parsed.push(feat); } const valRemap = new Map(); const keptValues = []; values.forEach((vb, idx) => { if (usedValues.has(idx)) { valRemap.set(idx, keptValues.length); keptValues.push(vb); } else { stats.droppedValues += 1; } }); const out = []; pbWriteVarint(out, 15 * 8 + 0); pbWriteVarint(out, version); if (name) pbWriteBytes(out, 1, name); for (const feat of parsed) pbWriteBytes(out, 2, encodeFeature(feat, keyRemap, valRemap)); for (const i of keptKeys) pbWriteBytes(out, 3, keys[i]); for (const vb of keptValues) pbWriteBytes(out, 4, vb); pbWriteVarint(out, 5 * 8 + 0); pbWriteVarint(out, extent); return Buffer.from(out); } function stripTile(buf, keepLayers) { const stats = { droppedKeys: 0, droppedValues: 0, droppedLayers: 0 }; const out = []; for (const f of pbFields(buf)) { if (f.field === 3 && f.wire === 2) { const layer = stripLayer(f.value, keepLayers, stats); if (layer) pbWriteBytes(out, 3, layer); } } return { buffer: Buffer.from(out), stats }; } /** * 清理不再需要的字体文件: * - 删掉不在 FONTSTACKS 里的字体栈目录(例如 --fonts 只留 Regular 时,删掉 Italic/Bold) * - 删掉不在 NEEDED_RANGES 里的区间文件 * 保证反复执行 pnpm map:fetch 时体积会收敛,而不是越积越多。 */ async function pruneFonts() { const fontsDir = path.join(OUT_DIR, 'fonts'); let dirs = []; try { dirs = await readdir(fontsDir, { withFileTypes: true }); } catch { return; } const keepStacks = new Set(FONTSTACKS); const keepRanges = new Set([...NEEDED_RANGES].map((r) => r * 256 + '-' + (r * 256 + 255))); let removed = 0; let freed = 0; for (const d of dirs) { if (!d.isDirectory()) continue; const full = path.join(fontsDir, d.name); if (!keepStacks.has(d.name)) { for (const f of await readdir(full)) { const s = await fileSize(path.join(full, f)); if (s > 0) freed += s; } await rm(full, { recursive: true, force: true }); removed += 1; log(' 移除未使用的字体栈: ' + d.name); continue; } for (const f of await readdir(full)) { if (!f.endsWith('.pbf')) continue; if (!keepRanges.has(f.slice(0, -4))) { const s = await fileSize(path.join(full, f)); if (s > 0) freed += s; await rm(path.join(full, f), { force: true }); removed += 1; } } } if (removed) log(' 清理多余字体文件 ' + removed + ' 个,释放 ' + fmtMB(freed)); } // -------------------------------------------------------------------------- // 1. 解析源样式 // -------------------------------------------------------------------------- log('> 读取源样式:', path.relative(ROOT, STYLE_SRC)); const style = JSON.parse(await readFile(STYLE_SRC, 'utf8')); const srcVec = style.sources && style.sources[VECTOR_SOURCE_ID]; if (!srcVec) throw new Error('源样式里找不到矢量源 ' + VECTOR_SOURCE_ID); let tileTemplate; if (srcVec.url) { log(' 矢量源 TileJSON:', srcVec.url); const { buffer } = await fetchBuffer(srcVec.url); const tilejson = JSON.parse(buffer.toString('utf8')); tileTemplate = tilejson.tiles[0]; } else { tileTemplate = srcVec.tiles[0]; } log(' 瓦片模板:', tileTemplate); // 收集样式中实际使用的字体栈 const fontstacks = new Set(); for (const layer of style.layers || []) { const tf = layer.layout && layer.layout['text-font']; if (Array.isArray(tf)) tf.forEach((f) => fontstacks.add(f)); } // 代码里 vjmap.Symbol 手动指定过的字体栈 for (const extra of String(args['extra-fonts'] ?? 'Noto Sans Regular').split(',')) { if (extra.trim()) fontstacks.add(extra.trim()); } let FONTSTACKS = [...fontstacks]; // 字体是体积大头:字形是 SDF 位图,单个区间约 150KB,一个字体栈 150 个区间就约 25MB, // 样式里 Regular/Italic/Bold 三个栈全下会到 75MB —— 比瓦片还大。 // 所以默认只保留一个字体栈(Noto Sans Regular),并把样式里所有 text-font 改写过去。 // --fonts="Noto Sans Regular,Noto Sans Italic" 保留多个字体栈 // --fonts= 留空则沿用样式里的全部字体栈 const FONTS_ARG = args.fonts === undefined ? 'Noto Sans Regular' : String(args.fonts); if (FONTS_ARG) { const list = FONTS_ARG.split(',') .map((s) => s.trim()) .filter(Boolean); if (list.length) FONTSTACKS = list; } log(' 字体栈:', FONTSTACKS.join(' | ')); // 样式引用到的 source-layer,精简瓦片时只保留这些图层 const KEEP_SOURCE_LAYERS = new Set((style.layers || []).map((l) => l['source-layer']).filter(Boolean)); // -------------------------------------------------------------------------- // 2. 下载矢量瓦片 // -------------------------------------------------------------------------- await ensureDir(path.join(OUT_DIR, 'tiles')); const tileJobs = []; for (let z = MINZOOM; z <= MAXZOOM; z++) { const r = tileRangeForZoom(z); for (let x = r.x0; x <= r.x1; x++) { for (let y = r.y0; y <= r.y1; y++) tileJobs.push({ z, x, y }); } } log('> 矢量瓦片: z' + MINZOOM + '-z' + MAXZOOM + ',共 ' + tileJobs.length + ' 张'); for (const t of TIERS) { log( ' z' + t.min + '-z' + t.max + ' lon[' + t.bbox[0] + ',' + t.bbox[2] + '] lat[' + t.bbox[1] + ',' + t.bbox[3] + ']', ); } let tilesBytes = 0; let tilesSkipped = 0; const t0 = Date.now(); await pool( tileJobs, CONCURRENCY, async (job) => { const dir = path.join(OUT_DIR, 'tiles', String(job.z), String(job.x)); const file = path.join(dir, job.y + '.pbf'); const existing = SKIP_EXISTING ? await fileSize(file) : -1; if (existing >= 0) { tilesBytes += existing; tilesSkipped += 1; return; } const url = tileTemplate .replace('{z}', job.z) .replace('{x}', job.x) .replace('{y}', job.y); const { buffer } = await fetchBuffer(url); await ensureDir(dir); // 空瓦片也写空文件,避免浏览器 404 反复报错 await writeFile(file, buffer); tilesBytes += buffer.length; }, (d, total) => { const elapsed = (Date.now() - t0) / 1000; log( ' ' + d + '/' + total + ' ' + fmtMB(tilesBytes) + ' 用时 ' + elapsed.toFixed(0) + 's (' + (d / Math.max(elapsed, 1)).toFixed(1) + ' 张/s)', ); }, ); log(' OK 瓦片完成: ' + fmtMB(tilesBytes) + (tilesSkipped ? '(跳过已存在 ' + tilesSkipped + ' 张)' : '')); // -------------------------------------------------------------------------- // 2.5 精简矢量瓦片(删除样式用不到的 name:xx / 值 / 图层) // -------------------------------------------------------------------------- let strippedBytes = tilesBytes; if (STRIP_TILES) { log('> 精简矢量瓦片:删除样式未使用的 name:xx 字段与图层 …'); const t1 = Date.now(); let before = 0; let after = 0; let droppedLayers = 0; let droppedKeys = 0; let touched = 0; await pool( tileJobs, 32, async (job) => { const file = path.join(OUT_DIR, 'tiles', String(job.z), String(job.x), job.y + '.pbf'); const size = await fileSize(file); if (size <= 0) return; const raw = await readFile(file); const { buffer, stats } = stripTile(raw, KEEP_SOURCE_LAYERS); droppedLayers += stats.droppedLayers; droppedKeys += stats.droppedKeys; if (buffer.length !== raw.length || stats.droppedLayers || stats.droppedKeys) { await writeFile(file, buffer); touched += 1; } before += raw.length; after += buffer.length; }, (d, total) => { if (d % 1000 === 0 || d === total) log(' ' + d + '/' + total + ' 用时 ' + ((Date.now() - t1) / 1000).toFixed(0) + 's'); }, ); strippedBytes = after; log( ' OK 精简完成: ' + fmtMB(before) + ' -> ' + fmtMB(after) + '(省 ' + fmtMB(before - after) + ',改写 ' + touched + ' 张;删除 name 键 ' + droppedKeys + ' 个、图层 ' + droppedLayers + ' 个)', ); } else { log(' 跳过瓦片精简(--no-strip)'); } // -------------------------------------------------------------------------- // 3. 统计瓦片内文字字符 -> 推导需要的字形区间(避免下载 50MB+ 全量 CJK) // -------------------------------------------------------------------------- const NEEDED_RANGES = new Set([0, 1]); if (GLYPH_MODE === 'all') { // 全量:0-65535 的 256 个区间。虽然体积最大,但可以彻底避免 // “某个字符所在区间没下载 → 空响应 → vjmap 报 i5.glyphs is not iterable”。 for (let r = 0; r <= 255; r++) NEEDED_RANGES.add(r); log(' 全量字形模式:每个字体栈 256 个区间'); } else if (GLYPH_MODE === 'all-cjk') { for (let r = 78; r <= 159; r++) NEEDED_RANGES.add(r); [2, 3, 4, 48, 49, 255].forEach((r) => NEEDED_RANGES.add(r)); } else { log('> 扫描瓦片文字,推导所需字形区间 …'); // 按 MVT 规范只取真正的字符串:key(layer 的 field 3)和 value 里的 string_value(value 的 field 1)。 // 不能用「把整个 value 消息当字符串解码」的做法——数字型 value 会被误解码成乱码字符, // 从而漏掉真正要用的区间(曾导致缺区间 → glyphs is not iterable)。 const chars = new Set(); const decoder = new TextDecoder('utf-8', { fatal: true }); function addString(bytes) { let s = null; try { s = decoder.decode(bytes); } catch { return; // 不是合法 UTF-8,忽略 } for (const ch of s) { const cp = ch.codePointAt(0); if (cp >= 0x20 && cp < 0x10000) chars.add(cp); // 字形只支持 BMP } } function collectTileStrings(buf) { for (const layerField of pbFields(buf)) { if (layerField.field !== 3 || layerField.wire !== 2) continue; for (const lf of pbFields(layerField.value)) { if (lf.field === 3 && lf.wire === 2) { addString(lf.value); // key } else if (lf.field === 4 && lf.wire === 2) { for (const vf of pbFields(lf.value)) { if (vf.field === 1 && vf.wire === 2) addString(vf.value); // Value.string_value } } } } } // 统计所有已下载瓦片里的文字:地图上渲染的每个字都来自这些瓦片, // 因此按瓦片字符集下载字形区间是完备的(精简瓦片后字符集会进一步变小) const scanMinzoom = Number(args['glyph-scan-minzoom'] ?? 0); let scanned = 0; for (const job of tileJobs) { if (job.z < scanMinzoom) continue; const fpath = path.join(OUT_DIR, 'tiles', String(job.z), String(job.x), job.y + '.pbf'); const size = await fileSize(fpath); if (size <= 0) continue; collectTileStrings(await readFile(fpath)); scanned++; } for (const cp of chars) NEEDED_RANGES.add(cp >> 8); // 兜底区间:ASCII、拉丁扩展、通用标点、CJK 标点、全角,以及数字(点位数量) [0, 1, 2, 3, 4, 32, 33, 48, 49, 255].forEach((r) => NEEDED_RANGES.add(r)); log(' 扫描 ' + scanned + ' 张瓦片,命中 ' + chars.size + ' 个字符,需要 ' + NEEDED_RANGES.size + ' 个字形区间'); } // -------------------------------------------------------------------------- // 4. 下载 glyphs 字体 // -------------------------------------------------------------------------- log('> 下载 glyphs 字体 …'); const glyphTemplate = style.glyphs.replace('{fontstack}', '__FONT__').replace('{range}', '__RANGE__'); let glyphBytes = 0; const glyphJobs = []; for (const font of FONTSTACKS) { for (const r of [...NEEDED_RANGES].sort((a, b) => a - b)) { const lo = r * 256; glyphJobs.push({ font, range: lo + '-' + (lo + 255) }); } } await pool( glyphJobs, CONCURRENCY, async ({ font, range }) => { const dir = path.join(OUT_DIR, 'fonts', font); const file = path.join(dir, range + '.pbf'); if (SKIP_EXISTING && (await fileSize(file)) > 0) return; const url = glyphTemplate .replace('__FONT__', encodeURIComponent(font)) .replace('__RANGE__', range); const { buffer, notFound } = await fetchBuffer(url); if (notFound || buffer.length === 0) return; await ensureDir(dir); await writeFile(file, buffer); glyphBytes += buffer.length; }, (d, total) => { if (d % 30 === 0 || d === total) log(' ' + d + '/' + total + ' ' + fmtMB(glyphBytes)); }, ); log( ' OK 字体完成: ' + fmtMB(glyphBytes) + '(' + FONTSTACKS.length + ' 个字体栈 x ' + NEEDED_RANGES.size + ' 个区间)', ); await pruneFonts(); // -------------------------------------------------------------------------- // 5. 下载 sprite 图标 // -------------------------------------------------------------------------- log('> 下载 sprite 图标 …'); await ensureDir(path.join(OUT_DIR, 'sprites')); const spriteBase = style.sprite; let spriteBytes = 0; for (const suffix of ['.json', '.png', '@2x.png']) { const { buffer } = await fetchBuffer(spriteBase + suffix); if (!buffer.length) continue; await writeFile(path.join(OUT_DIR, 'sprites', 'ofm' + suffix), buffer); spriteBytes += buffer.length; } log(' OK sprite 完成: ' + fmtMB(spriteBytes)); // -------------------------------------------------------------------------- // 6. 生成本地化样式 // -------------------------------------------------------------------------- log('> 生成本地化样式 …'); const outStyle = JSON.parse(JSON.stringify(style)); const usedSources = new Set((style.layers || []).map((l) => l.source).filter(Boolean)); const sources = {}; for (const [id, src] of Object.entries(style.sources)) { if (!usedSources.has(id) && UNUSED_SOURCES.includes(id)) { log(' 移除未被引用的瓦片源: ' + id); continue; } if (id === VECTOR_SOURCE_ID) { sources[id] = { type: 'vector', scheme: 'xyz', tiles: [BASE + 'tiles/{z}/{x}/{y}.pbf'], minzoom: MINZOOM, maxzoom: MAXZOOM, }; if (src.attribution) sources[id].attribution = src.attribution; continue; } sources[id] = src; } outStyle.sources = sources; outStyle.glyphs = BASE + 'fonts/{fontstack}/{range}.pbf'; if (FONTS_ARG && FONTSTACKS.length) { const only = FONTSTACKS[0]; for (const l of outStyle.layers || []) { if (l.layout && Array.isArray(l.layout['text-font'])) l.layout['text-font'] = [only]; } log(' 已将样式内全部 text-font 统一为: ' + only); } if (SPRITE_BASE) { outStyle.sprite = SPRITE_BASE; log(' sprite 使用绝对地址: ' + SPRITE_BASE); } else { delete outStyle.sprite; log(' 不写入 sprite 字段(vjmap 只接受绝对地址;地图正常,仅 POI 图标不显示)'); log(' 需要图标请加 --sprite-base=http://<内网域名或IP>/map/sprites/ofm'); } outStyle.name = (style.name || 'Custom') + ' (offline)'; // 只记录 OpenFreeMap 的数据版本号,不残留任何外网 URL const sourceVersion = (/:\/\/[^/]+\/planet\/([^/]+)\//.exec(tileTemplate) || [])[1] || ''; outStyle.metadata = Object.assign({}, outStyle.metadata || {}, { 'x-offline': { generatedAt: new Date().toISOString(), sourceVersion, tiers: TIERS.map((t) => ({ zoom: 'z' + t.min + '-z' + t.max, bbox: t.bbox })), minzoom: MINZOOM, maxzoom: MAXZOOM, }, }); await ensureDir(path.dirname(STYLE_OUT)); // 样式内容没变化时不重写文件,避免每次 map:fetch 都产生一个只有时间戳差异的 diff const sameStyle = await isSameStyle(outStyle); if (sameStyle) { log(' 样式内容未变化,跳过写入: ' + path.relative(ROOT, STYLE_OUT)); } else { await writeFile(STYLE_OUT, JSON.stringify(outStyle, null, 2)); } /** 忽略 x-offline.generatedAt,判断新旧样式是否等价 */ async function isSameStyle(next) { let prevText; try { prevText = await readFile(STYLE_OUT, 'utf8'); } catch { return false; } try { const prev = JSON.parse(prevText); const norm = (s) => { const c = JSON.parse(JSON.stringify(s)); if (c.metadata && c.metadata['x-offline']) c.metadata['x-offline'].generatedAt = ''; return JSON.stringify(c); }; return norm(prev) === norm(next); } catch { return false; } } // -------------------------------------------------------------------------- // 汇总 // -------------------------------------------------------------------------- log(''); log('======== 完成 ========'); log('样式文件 :', path.relative(ROOT, STYLE_OUT)); log('矢量瓦片 :', fmtMB(strippedBytes), '(' + tileJobs.length + ' 张' + (STRIP_TILES ? ',已精简' : '') + ')'); log('字体字形 :', fmtMB(glyphBytes)); log('图标雪碧 :', fmtMB(spriteBytes)); log('合计 :', fmtMB(strippedBytes + glyphBytes + spriteBytes)); log('资源前缀 :', BASE); log('提示 : 用 --skip-existing 断点续传;--maxzoom=12 可再细一级。');