// 将 img/ 目录下的 SVG 底图转换为 TS 模块 // 使用方式:pnpm generate-maps const fs = require('fs') const path = require('path') const imgDir = path.resolve(__dirname, '../apps/property-survey/img') const outputFile = path.resolve( __dirname, '../apps/property-survey/src/features/map/floorMaps.generated.ts', ) // 按楼层查找候选文件名(优先带 -1 后缀的新版本) const FLOOR_CANDIDATES = { 1: ['1101-1.svg', '1101.svg'], 2: ['1102-1.svg', '1102.svg'], 3: ['1103-1.svg', '1103.svg'], 4: ['1104-1.svg', '1104.svg'], } function findFile(candidates) { for (const name of candidates) { const full = path.join(imgDir, name) if (fs.existsSync(full)) return full } return null } /** * 解析 SVG transform 字符串,返回 matrix(a,b,c,d,e,f) 的 6 个分量 * 支持 rotate(angle) / rotate(angle,cx,cy) / matrix(a,b,c,d,e,f) */ function parseTransform(transformStr) { if (!transformStr) return [1, 0, 0, 1, 0, 0] const rotateM = transformStr.trim().match(/^rotate\(\s*([^,)]+)(?:\s*,\s*([^,)]+)\s*,\s*([^)]+))?\s*\)$/) if (rotateM) { const angle = parseFloat(rotateM[1]) * Math.PI / 180 const rcx = rotateM[2] ? parseFloat(rotateM[2]) : 0 const rcy = rotateM[3] ? parseFloat(rotateM[3]) : 0 const cosA = Math.cos(angle), sinA = Math.sin(angle) return [ cosA, sinA, -sinA, cosA, rcx * (1 - cosA) + rcy * sinA, rcy * (1 - cosA) - rcx * sinA, ] } const matrixM = transformStr.trim().match(/^matrix\(\s*([^)]+)\s*\)$/) if (matrixM) { return matrixM[1].split(/[\s,]+/).map(parseFloat) } return [1, 0, 0, 1, 0, 0] } /** * 从 SVG 提取所有用户嵌入的 元素(非背景图) * 返回结构化数据,供 FloorMap.tsx 用原生 Image 渲染 */ function extractEmbeddedImages(svgText) { const results = [] const regex = /]*)\/>/gs let m while ((m = regex.exec(svgText)) !== null) { const attrs = m[0] const uriM = attrs.match(/(?:xlink:href|href)="(data:image\/[^"]+)"/) if (!uriM) continue const uri = uriM[1].replace(/ | |\s/g, '') const W = parseFloat(attrs.match(/width="([^"]*)"/)?.[1] || '0') const H = parseFloat(attrs.match(/height="([^"]*)"/)?.[1] || '0') const ox = parseFloat(attrs.match(/\sx="([^"]*)"/)?.[1] || '0') const oy = parseFloat(attrs.match(/\sy="([^"]*)"/)?.[1] || '0') const opacityStyle = attrs.match(/opacity:([^;}"]*)/)?.[1] const opacityAttr = attrs.match(/\sopacity="([^"]*)"/)?.[1] const opacity = parseFloat(opacityStyle ?? opacityAttr ?? '1') const transformStr = attrs.match(/transform="([^"]*)"/)?.[1] const [a, b, c, d, e, f] = parseTransform(transformStr) // 图片在 SVG 坐标系下的中心点 const cx = a * (ox + W / 2) + c * (oy + H / 2) + e const cy = b * (ox + W / 2) + d * (oy + H / 2) + f // 旋转角度(度) const angleDeg = Math.atan2(b, a) * (180 / Math.PI) results.push({ uri, width: W, height: H, cx, cy, angleDeg, opacity }) } return results } /** * 移除 SVG 文本中的第一个 元素(背景图) */ function removeFirstImage(svg) { const m = svg.match(/(?:xlink:href|href)="(data:image\/[^"]+)"/) if (!m) return svg const tagStart = svg.lastIndexOf('', tagStart) const tagEnd = svg[gtPos - 1] === '/' ? gtPos + 1 : (() => { const close = svg.indexOf('', gtPos) return close !== -1 ? close + 8 : gtPos + 1 })() return svg.slice(0, tagStart) + svg.slice(tagEnd) } /** * 移除 SVG 文本中的所有 元素(已提取到 embeddedImages 后清理) */ function removeAllImages(svg) { return svg.replace(/]*\/>/gs, '') } const contents = {} for (const [floor, candidates] of Object.entries(FLOOR_CANDIDATES)) { const filePath = findFile(candidates) if (!filePath) { console.error(`❌ 找不到 ${floor}F 的 SVG 文件(查找:${candidates.join(', ')})`) process.exit(1) } console.log(`读取 ${floor}F → ${path.basename(filePath)}`) const svg = fs.readFileSync(filePath, 'utf8') // 1. 提取背景图 URI(第一个 ) const bgMatch = svg.match(/(?:xlink:href|href)="(data:image\/[^"]+)"/) const imageUri = bgMatch ? bgMatch[1].replace(/ | |\s/g, '') : null if (imageUri) console.log(` → 提取背景图 URI`) // 2. 移除背景图后,提取剩余嵌入图片 const svgNoBg = removeFirstImage(svg) const embeddedImages = extractEmbeddedImages(svgNoBg) console.log(` → 嵌入图片(用户添加): ${embeddedImages.length} 张`) // 3. 清理 SVG:移除所有 (已单独提取)、Inkscape 专属元素和属性 let svgXml = removeAllImages(svgNoBg) svgXml = svgXml.replace(//g, '') svgXml = svgXml.replace(//g, '') svgXml = svgXml.replace(/\s+(?:inkscape|sodipodi):[a-z:A-Z-]+=(?:"[^"]*"|'[^']*')/g, '') svgXml = svgXml.replace(/\s+xmlns:(?:inkscape|sodipodi)="[^"]*"/g, '') svgXml = svgXml.replace(/\s+xmlns:svg="[^"]*"/g, '') // 4. 确保 viewBox,将 width/height 改为 100% const hasViewBox = /viewBox=/.test(svgXml) const wMatch = svgXml.match(/]*\swidth="([^"%][^"]*)"/) const hMatch = svgXml.match(/]*\sheight="([^"%][^"]*)"/) if (!hasViewBox && wMatch && hMatch) { svgXml = svgXml.replace(/]*)\swidth="[^%][^"]*"/, '$1 width="100%"') svgXml = svgXml.replace(/(]*)\sheight="[^%][^"]*"/, '$1 height="100%"') svgXml = svgXml.replace(/xlink:href/g, 'href') svgXml = svgXml.replace(/<\?xml[^?]*\?>/g, '').trim() svgXml = svgXml.replace(//g, '') svgXml = svgXml.replace(/\n\s*\n/g, '\n').trim() contents[floor] = { imageUri, svgXml, embeddedImages } } const output = `// 自动生成,请勿手动编辑 // 更新底图后运行:pnpm generate-maps import type { FloorNumber } from '@repo/types' export type EmbeddedImage = { uri: string width: number // SVG 单位 height: number // SVG 单位 cx: number // 图片中心 x(SVG 坐标系,0-3200) cy: number // 图片中心 y(SVG 坐标系,0-2133) angleDeg: number // 旋转角度(度) opacity: number } export type FloorMap = { imageUri: string | null svgXml: string embeddedImages: EmbeddedImage[] } const f1: FloorMap = ${JSON.stringify(contents[1])} const f2: FloorMap = ${JSON.stringify(contents[2])} const f3: FloorMap = ${JSON.stringify(contents[3])} const f4: FloorMap = ${JSON.stringify(contents[4])} export const FLOOR_SVG: Record = { 1: f1, 2: f2, 3: f3, 4: f4 } ` fs.writeFileSync(outputFile, output, 'utf8') console.log(`✅ 已生成 ${outputFile}`)