#!/usr/bin/env node /** * Android → HarmonyOS 资源转换器(规范实现,第一期) * * 本脚本是 hmos-resources-convert skill 的**唯一**转换实现。agent 不再自行编写 * 转换脚本——历史上每个 cell 各写一套(10 个 cell、3 种语言、约 1 万行), * 导致同一份 APK 跑出互不相同的结果、计数口径不一致、部分 cell 还引入了 * 浮动版本的 npm 依赖。 * * 覆盖范围(第一期): * - values/*.xml → base/element/*.json(string / color / float / integer / * boolean / strarray / intarray / plural) * - 限定符目录映射(密度、语言地区、横竖屏、深色、API 级别剥离、屏幕尺寸跳过) * - 媒体文件直拷 + 点九图重命名(.9.png → _9.png) * - raw/ → rawfile/,font/ → rawfile/fonts/,xml/ → base/profile/ * - 不可映射类型(layout / menu / anim / animator / color / interpolator …)留记录 * - inventory + mapping 记录(条目级),供报告与 mapping 文档使用 * * 尚未覆盖(后续期次,当前只登记为 pending,不静默丢弃): * - XML drawable → SVG(vector / shape / layer-list / selector) * - adaptive-icon → layered-image JSON + 纯色 PNG * - 引用解析链、系统资源表、fallback * * 零第三方依赖,仅使用 Node 内置模块。需要 Node >= 22.18(原生 strip-types)。 * * 用法: * node a2h_resource_convert.ts --res --harmony * [--records ] [--dry-run] [--quiet] * * 退出码: * 0 转换完成(pending 项不算失败,但会计数并在 records 中留行) * 1 存在转换失败项 * 2 用法错误 / 路径不存在 */ import * as fs from 'node:fs'; import * as path from 'node:path'; import * as zlib from 'node:zlib'; // ───────────────────────────────────────────── // 常量表 // ───────────────────────────────────────────── /** Android 密度限定符 → HarmonyOS 密度限定符。见 conversion-rules.md「Density Qualifiers」。 */ const DENSITY: Record = { ldpi: 'sdpi', mdpi: 'mdpi', tvdpi: 'ldpi', hdpi: 'ldpi', xhdpi: 'xldpi', xxhdpi: 'xxldpi', xxxhdpi: 'xxxldpi', nodpi: null, anydpi: null, // → base,不产生限定符 }; const ORIENTATION: Record = { land: 'horizontal', port: 'vertical' }; const COLORMODE: Record = { night: 'dark', notnight: 'light' }; /** 静默剥离:无 HarmonyOS 对应物,但不影响资源本身的适用性。 */ const STRIP_RE = /^(v\d+|ldrtl|ldltr|round|notround|highdr|lowdr|long|notlong)$/; /** 整目录跳过:屏幕尺寸类限定符,HarmonyOS 无等价物,转过去会是错的。 */ const SKIP_RE = /^(sw\d+dp|w\d+dp|h\d+dp|small|normal|large|xlarge)$/; /** 设备类型限定符 → HarmonyOS 设备限定符。 */ const DEVICE: Record = { watch: 'wearable', television: 'tv', car: 'car', desk: 'phone', appliance: 'phone', }; const IMAGE_EXT = new Set(['.png', '.jpg', '.jpeg', '.gif', '.webp', '.bmp', '.svg']); const FONT_EXT = new Set(['.ttf', '.otf', '.ttc']); /** 目录类型 → 处理方式。 */ const UNMAPPABLE_TYPES = new Set([ 'layout', 'menu', 'anim', 'animator', 'color', 'interpolator', 'transition', 'navigation', 'id', 'ids', ]); /** * Android 框架内置颜色。见 SKILL.md Step 5.2。 * HarmonyOS 没有 Android 系统资源概念,`@android:` 引用**绝不能**留在输出里。 */ const ANDROID_SYSTEM_COLORS: Record = { white: '#ffffffff', black: '#ff000000', transparent: '#00000000', background_dark: '#ff000000', background_light: '#ffffffff', darker_gray: '#ffaaaaaa', holo_blue_bright: '#ff00ddff', holo_blue_dark: '#ff0099cc', holo_blue_light: '#ff33b5e5', holo_green_dark: '#ff669900', holo_green_light: '#ff99cc00', holo_orange_dark: '#ffff8800', holo_orange_light: '#ffffbb33', holo_purple: '#ffaa66cc', holo_red_dark: '#ffcc0000', holo_red_light: '#ffff4444', primary_text_dark: '#ffffffff', primary_text_light: '#de000000', secondary_text_dark: '#b3ffffff', secondary_text_light: '#8a000000', tab_indicator_text: '#ff808080', tertiary_text_dark: '#80ffffff', tertiary_text_light: '#54000000', widget_edittext_dark: '#ff000000', }; /** Android 框架内置尺寸。见 SKILL.md Step 5.2。 */ const ANDROID_SYSTEM_DIMENS: Record = { app_icon_size: '48vp', thumbnail_width: '96vp', thumbnail_height: '96vp', dialog_min_width_major: '65%', dialog_min_width_minor: '95%', notification_large_icon_width: '64vp', notification_large_icon_height: '64vp', }; /** Android 框架内置字符串(英文取值)。 */ const ANDROID_SYSTEM_STRINGS: Record = { ok: 'OK', cancel: 'Cancel', yes: 'Yes', no: 'No', copy: 'Copy', cut: 'Cut', paste: 'Paste', selectAll: 'Select all', search_go: 'Search', unknownName: 'Unknown', }; /** 无法解析时按类型使用的兜底值。见 SKILL.md Step 5.2「Fallback values by type」。 */ const FALLBACK_BY_KEY: Record = { color: '#ff000000', float: '0vp', string: '', integer: 0, boolean: false, }; /** * 解码后的 APK 里,枚举属性是数字而不是名字(aapt 编译后的值)。 * 直接把数字写进 SVG 会得到非法的 `stroke-linecap="2"`。 */ const STROKE_LINECAP = ['butt', 'round', 'square']; const STROKE_LINEJOIN = ['miter', 'round', 'bevel']; /** Android 尺寸单位 → HarmonyOS 单位 / 换算系数。 */ const DIMEN_UNITS: Array<{ re: RegExp; unit: string; factor: number }> = [ { re: /^(-?[\d.]+)dip$/i, unit: 'vp', factor: 1 }, { re: /^(-?[\d.]+)dp$/i, unit: 'vp', factor: 1 }, { re: /^(-?[\d.]+)sp$/i, unit: 'fp', factor: 1 }, { re: /^(-?[\d.]+)px$/i, unit: 'px', factor: 1 }, { re: /^(-?[\d.]+)pt$/i, unit: 'vp', factor: 1.33 }, { re: /^(-?[\d.]+)in$/i, unit: 'vp', factor: 160 }, { re: /^(-?[\d.]+)mm$/i, unit: 'vp', factor: 6.3 }, { re: /^(-?[\d.]+)$/, unit: 'vp', factor: 1 }, ]; /** Android values 标签 → HarmonyOS element 文件名与 JSON 根键。 */ const ELEMENT_KIND: Record = { string: { file: 'string.json', key: 'string' }, color: { file: 'color.json', key: 'color' }, dimen: { file: 'float.json', key: 'float' }, integer: { file: 'integer.json', key: 'integer' }, bool: { file: 'boolean.json', key: 'boolean' }, 'string-array': { file: 'strarray.json', key: 'strarray' }, 'integer-array': { file: 'intarray.json', key: 'intarray' }, plurals: { file: 'plural.json', key: 'plural' }, }; /** 这些 values 标签在 HarmonyOS 没有等价物,登记为 unmappable。 */ const VALUES_UNMAPPABLE = new Set([ 'style', 'declare-styleable', 'attr', 'item', 'eat-comment', 'skip', 'fraction', 'macro', ]); // ───────────────────────────────────────────── // 类型 // ───────────────────────────────────────────── interface XmlNode { name: string; attrs: Record; children: XmlNode[]; /** 元素内部的原始源码片段(含子标签),字符串资源需要它 */ inner: string; } /** Android 资源清单行。字段对齐 references/resource-mapping-rules.md §2.1。 */ interface InventoryRecord { androidResourcePath: string; androidResourceName: string; function: string; screens: string; sourceCategory: string; typeCategory: string; status: string; notes: string; } /** Android ↔ HarmonyOS 映射行。字段对齐 §2.2。 */ interface MappingRecord { androidResourcePath: string; androidScreens: string; androidSourceCategory: string; androidTypeCategory: string; harmonyTarget: string; mappingKind: string; notes: string; } interface QualifierPlan { /** HarmonyOS 限定符目录名,`base` 表示无限定符 */ dir: string; /** 整目录跳过时的原因 */ skip: string | null; stripped: string[]; } // ───────────────────────────────────────────── // 工具 // ───────────────────────────────────────────── function fail(msg: string): never { console.error(`[a2h_resource_convert] ${msg}`); process.exit(2); } const ENTITIES: Record = { amp: '&', lt: '<', gt: '>', quot: '"', apos: "'", nbsp: ' ', }; function decodeEntities(s: string): string { return s.replace(/&(#x?[0-9a-fA-F]+|[a-zA-Z]+);/g, (m, body: string) => { if (body[0] === '#') { const code = body[1] === 'x' || body[1] === 'X' ? parseInt(body.slice(2), 16) : parseInt(body.slice(1), 10); return Number.isFinite(code) ? String.fromCodePoint(code) : m; } return ENTITIES[body] ?? m; }); } // ───────────────────────────────────────────── // 极简 XML 解析器 // ───────────────────────────────────────────── /** * 只覆盖 Android 资源 XML 需要的语法:元素、属性、文本、CDATA、注释、 * 声明与 DOCTYPE。不做 DTD、实体声明、命名空间解析(属性名按原样保留, * 调用方用 localName 比对)。 */ function parseXml(src: string): XmlNode { let i = 0; const n = src.length; function skipUntil(marker: string): void { const at = src.indexOf(marker, i); i = at < 0 ? n : at + marker.length; } function parseAttrs(end: number): Record { const attrs: Record = {}; const re = /([^\s=/>]+)\s*=\s*("([^"]*)"|'([^']*)')/g; const slice = src.slice(i, end); let m: RegExpExecArray | null; while ((m = re.exec(slice)) !== null) { attrs[m[1]] = decodeEntities(m[3] ?? m[4] ?? ''); } return attrs; } function parseNode(): XmlNode | null { while (i < n) { const lt = src.indexOf('<', i); if (lt < 0) return null; i = lt; if (src.startsWith(''); continue; } if (src.startsWith(''); continue; } if (src.startsWith(''); continue; } if (src.startsWith('= n) return null; const gt = src.indexOf('>', i); if (gt < 0) return null; const selfClosing = src[gt - 1] === '/'; const head = src.slice(i + 1, selfClosing ? gt - 1 : gt); const name = head.split(/[\s/]/, 1)[0]; const attrs = parseAttrs(gt); i = gt + 1; const node: XmlNode = { name, attrs, children: [], inner: '' }; if (selfClosing) return node; const innerStart = i; // 逐段消费,直到遇到本元素的结束标签 while (i < n) { const lt = src.indexOf('<', i); if (lt < 0) { i = n; break; } if (src.startsWith(''); continue; } if (src.startsWith(''); continue; } if (src.startsWith(`', lt); i = i < 0 ? n : i + 1; return node; } i = lt; const child = parseNode(); if (child) node.children.push(child); else { i = lt + 1; } } node.inner = src.slice(innerStart); return node; } const root = parseNode(); return root ?? { name: '', attrs: {}, children: [], inner: '' }; } /** 属性按 localName 取值(忽略 `android:` 之类的前缀)。 */ function attr(node: XmlNode, local: string): string | undefined { for (const [k, v] of Object.entries(node.attrs)) { if (k === local || k.endsWith(`:${local}`)) return v; } return undefined; } // ───────────────────────────────────────────── // 值转换 // ───────────────────────────────────────────── /** * Android 字符串字面量 → HarmonyOS 字符串值。 * * 顺序很重要:先取 CDATA / 去标签,再解实体,再解 Android 反斜杠转义, * 最后剥掉 Android 的成对外层引号(那对引号是用来保护首尾空白的语法, * 不是文本内容——SKILL.md Step 6.11 专门校验这一点)。 */ function normalizeString(inner: string): string { let s = inner; const cdata = [...s.matchAll(//g)]; if (cdata.length > 0) s = cdata.map((m) => m[1]).join(''); s = decodeEntities(s); s = s.replace(/\\(['"@?\\])/g, '$1').replace(/\\n/g, '\n').replace(/\\t/g, '\t'); if (s.length >= 2 && s.startsWith('"') && s.endsWith('"')) s = s.slice(1, -1); return s; } /** `#RGB` / `#ARGB` / `#RRGGBB` / `#AARRGGBB` → `#AARRGGBB`(小写)。 */ function normalizeColor(raw: string): string | null { const v = raw.trim(); const m = /^#([0-9a-fA-F]{3,8})$/.exec(v); if (!m) return null; const h = m[1].toLowerCase(); if (h.length === 3) return `#ff${h[0]}${h[0]}${h[1]}${h[1]}${h[2]}${h[2]}`; if (h.length === 4) return `#${h[0]}${h[0]}${h[1]}${h[1]}${h[2]}${h[2]}${h[3]}${h[3]}`; if (h.length === 6) return `#ff${h}`; if (h.length === 8) return `#${h}`; return null; } /** Android 尺寸 → HarmonyOS 尺寸字符串。无法解析时返回 null(调用方登记 fallback)。 */ function normalizeDimen(raw: string): string | null { const v = raw.trim(); for (const { re, unit, factor } of DIMEN_UNITS) { const m = re.exec(v); if (!m) continue; const num = parseFloat(m[1]); if (!Number.isFinite(num)) return null; // 一律收敛到 3 位小数:resources.arsc 里的定点数解码后常带浮点噪声 // (例如 6.500001dip),原样输出会把噪声带进 HarmonyOS 资源。 const scaled = Math.round(num * factor * 1000) / 1000; return `${scaled}${unit}`; } return null; } function normalizeInteger(raw: string): number | null { const v = raw.trim(); const parsed = /^0[xX]/.test(v) ? parseInt(v, 16) : parseInt(v, 10); return Number.isFinite(parsed) ? parsed : null; } // ───────────────────────────────────────────── // 纯色 PNG 生成(zlib + 手写 PNG 封装,避免引入 pngjs) // ───────────────────────────────────────────── let CRC_TABLE: Int32Array | null = null; function crc32(buf: Buffer): number { if (!CRC_TABLE) { CRC_TABLE = new Int32Array(256); for (let n = 0; n < 256; n++) { let c = n; for (let k = 0; k < 8; k++) c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1; CRC_TABLE[n] = c; } } let c = -1; for (let i = 0; i < buf.length; i++) c = CRC_TABLE[(c ^ buf[i]) & 0xff] ^ (c >>> 8); return (c ^ -1) >>> 0; } function pngChunk(type: string, data: Buffer): Buffer { const len = Buffer.alloc(4); len.writeUInt32BE(data.length, 0); const body = Buffer.concat([Buffer.from(type, 'latin1'), data]); const crc = Buffer.alloc(4); crc.writeUInt32BE(crc32(body), 0); return Buffer.concat([len, body, crc]); } /** 生成 size×size 的纯色 RGBA PNG。`#AARRGGBB` 输入。 */ function solidPng(argb: string, size = 108): Buffer { const h = argb.replace('#', ''); const a = parseInt(h.slice(0, 2), 16); const r = parseInt(h.slice(2, 4), 16); const g = parseInt(h.slice(4, 6), 16); const b = parseInt(h.slice(6, 8), 16); const row = Buffer.alloc(1 + size * 4); for (let x = 0; x < size; x++) { row[1 + x * 4] = r; row[2 + x * 4] = g; row[3 + x * 4] = b; row[4 + x * 4] = a; } const raw = Buffer.concat(Array.from({ length: size }, () => row)); const ihdr = Buffer.alloc(13); ihdr.writeUInt32BE(size, 0); ihdr.writeUInt32BE(size, 4); ihdr[8] = 8; // bit depth ihdr[9] = 6; // color type RGBA return Buffer.concat([ Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]), pngChunk('IHDR', ihdr), pngChunk('IDAT', zlib.deflateSync(raw, { level: 9 })), pngChunk('IEND', Buffer.alloc(0)), ]); } // ───────────────────────────────────────────── // XML drawable → SVG // ───────────────────────────────────────────── interface SvgPaint { color: string; opacity: number | null; } /** `#AARRGGBB` / `#RRGGBB` / 简写 → SVG 的 `#RRGGBB` + 独立 opacity。 */ function androidColorToSvg(raw: string): SvgPaint | null { const c = normalizeColor(raw); if (!c) return null; const h = c.slice(1); const alpha = parseInt(h.slice(0, 2), 16) / 255; return { color: `#${h.slice(2)}`, opacity: alpha >= 0.999 ? null : Math.round(alpha * 1000) / 1000, }; } /** `24dp` / `24dip` / `24` → `24`(SVG 用户单位)。 */ function svgNum(raw: string | undefined, fallback: number): number { if (!raw) return fallback; const m = /^(-?[\d.]+)/.exec(raw.trim()); const v = m ? parseFloat(m[1]) : NaN; return Number.isFinite(v) ? v : fallback; } function esc(s: string): string { return s.replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"'); } // ───────────────────────────────────────────── // 限定符目录解析 // ───────────────────────────────────────────── /** * `drawable-zh-rCN-night-hdpi` → { type: 'drawable', plan: { dir: 'zh_CN-dark-ldpi' } } * * 输出顺序遵循 HarmonyOS 约定: * MCC_MNC-language_script_country-orientation-device-colormode-density */ function planQualifiers(dirName: string): { type: string; plan: QualifierPlan } { const segs = dirName.split('-'); const type = segs[0]; const rest = segs.slice(1); let language = ''; let script = ''; let region = ''; let orientation = ''; let device = ''; let colormode = ''; let density = ''; const stripped: string[] = []; for (let k = 0; k < rest.length; k++) { const q = rest[k]; if (SKIP_RE.test(q)) { return { type, plan: { dir: '', skip: `Unsupported qualifier: ${q} — no HarmonyOS equivalent`, stripped } }; } if (STRIP_RE.test(q)) { stripped.push(q); continue; } if (q in DENSITY) { const d = DENSITY[q]; if (d) density = d; else stripped.push(q); // nodpi / anydpi → base continue; } if (q in ORIENTATION) { orientation = ORIENTATION[q]; continue; } if (q in COLORMODE) { colormode = COLORMODE[q]; continue; } if (q in DEVICE) { device = DEVICE[q]; continue; } if (/^b\+/.test(q)) { // BCP 47:b+sr+Latn const parts = q.slice(2).split('+'); language = parts[0] ?? ''; if (parts[1] && parts[1].length === 4) script = parts[1]; if (parts[2]) region = parts[2]; continue; } // 地区限定符:ISO 3166-1 两位字母(rCN),或 UN M.49 三位数字(r419 = 拉丁美洲)。 // 只认两位字母的话,`values-es-r419` 会被当成无法识别而剥离,直接并进通用 `es`—— // 拉美西班牙语会覆盖掉欧洲西班牙语,是静默的错译。HarmonyOS 的地区位同样接受三位数字。 if (/^r(?:[A-Z]{2}|\d{3})$/.test(q) && language) { region = q.slice(1); continue; } if (/^[a-z]{2,3}$/.test(q) && !language) { language = q; continue; } if (/^mcc\d+$/.test(q) || /^mnc\d+$/.test(q)) { stripped.push(q); continue; } stripped.push(q); // 其余未识别限定符:剥离,不拒绝 } const locale = [language, script, region].filter(Boolean).join('_'); const dir = [locale, orientation, device, colormode, density].filter(Boolean).join('-') || 'base'; return { type, plan: { dir, skip: null, stripped } }; } // ───────────────────────────────────────────── // 转换主体 // ───────────────────────────────────────────── interface ElementEntry { name: string; value: unknown; /** Android 侧原始文本,引用解析回溯时要用 */ raw: string; sourcePath: string; } /** 一条引用解析记录,供报告的「Resource Reference Resolution」小节使用。 */ interface ResolutionRecord { file: string; name: string; reference: string; resolved: string; chain: string; kind: 'project' | 'system' | 'fallback' | 'theme-attr'; } class Converter { resDir: string; outRoot: string; dryRun: boolean; /** qualifierDir → elementFile → name → entry */ elements = new Map>>(); /** base 限定符下的原始 color / dimen 值,供 SVG 转换时就地解析引用 */ rawColors = new Map(); rawDimens = new Map(); /** base 限定符下的原始 string 值,pathData 可能以 `@string/xxx` 形式引用它们 */ rawStrings = new Map(); rawIntegers = new Map(); rawBools = new Map(); resolutions: ResolutionRecord[] = []; /** 每处理一个文件记一条,用于报告里的「限定符映射」聚合表 */ qualifierUse: Array<{ android: string; harmony: string }> = []; /** 已生成的纯色 PNG,避免重复生成 */ generatedPngs = new Set(); inventory: InventoryRecord[] = []; mappings: MappingRecord[] = []; warnings: string[] = []; conflicts: string[] = []; counts = { valuesEntries: 0, mediaCopied: 0, ninePatchRenamed: 0, rawCopied: 0, fontCopied: 0, profileCopied: 0, unmappable: 0, skippedQualifierDirs: 0, failed: 0, svgConverted: 0, layeredImages: 0, solidPngGenerated: 0, systemColorsResolved: 0, referencesResolved: 0, systemReferencesResolved: 0, fallbacksApplied: 0, unresolvedPaint: 0, mediaTargetCollisions: 0, }; constructor(resDir: string, harmonyDir: string, dryRun: boolean) { this.resDir = resDir; this.outRoot = path.join(harmonyDir, 'entry', 'src', 'main', 'resources'); this.dryRun = dryRun; } // ── 记录 ── inv(r: Partial & { androidResourcePath: string; androidResourceName: string }): void { this.inventory.push({ function: '', screens: 'Common', sourceCategory: '应用自身资源', typeCategory: 'drawable', status: 'converted', notes: '', ...r, }); } map(r: Partial & { androidResourcePath: string }): void { this.mappings.push({ androidScreens: 'Common', androidSourceCategory: '应用自身资源', androidTypeCategory: 'drawable', harmonyTarget: 'N/A', mappingKind: 'unmappable', notes: '', ...r, }); } /** * 资源来源类别推断。仅使用可证据化的前缀约定, * 见 resource-mapping-rules.md §7.3;无证据时归为应用自身资源。 */ sourceCategory(name: string): string { return /^(abc_|mtrl_|material_|design_|m3_|androidx_|exo_|preference_|common_|gcm_|fcm_|play_)/.test(name) ? '第三方库资源' : '应用自身资源'; } /** 目标媒体路径 → 已占用它的 Android 源文件与其限定符个数 */ claimedTargets = new Map(); /** * 多个 Android 源可能落到同一个 HarmonyOS 目标:`drawable/x.xml` 与 * `drawable-anydpi/x.xml`(anydpi→base)、`drawable-v31/x.xml`(v31 剥离)都写 * `base/media/x.svg`。HarmonyOS 表达不了这些限定符,必须择一。 * * 规则:**限定符更少者胜**——无限定符的资源是无条件生效的那一份。 * 落败者不静默丢弃,仍产出 unmappable 映射行说明原因。 * 返回 false 表示本次不应写入。 */ claimTarget(targetRel: string, relFile: string, androidPath: string, type: string): boolean { const quals = relFile.split('/')[0].split('-').length - 1; const prev = this.claimedTargets.get(targetRel); if (!prev) { this.claimedTargets.set(targetRel, { source: relFile, quals }); return true; } this.counts.mediaTargetCollisions++; const loser = quals < prev.quals ? prev.source : relFile; const winner = quals < prev.quals ? relFile : prev.source; const note = `HarmonyOS target collision on ${targetRel}: kept res/${winner} (fewer qualifiers), dropped res/${loser}`; this.warnings.push(note); this.map({ androidResourcePath: `res/${loser}`, androidTypeCategory: type, androidSourceCategory: this.sourceCategory(path.basename(loser).replace(/\.[^.]+$/, '')), mappingKind: 'unmappable', notes: note, }); if (quals < prev.quals) { this.claimedTargets.set(targetRel, { source: relFile, quals }); return true; } return false; } write(rel: string, data: string | Buffer): void { if (this.dryRun) return; const full = path.join(this.outRoot, rel); fs.mkdirSync(path.dirname(full), { recursive: true }); fs.writeFileSync(full, data); } // ── values ── addElement(qdir: string, file: string, entry: ElementEntry): void { let byFile = this.elements.get(qdir); if (!byFile) { byFile = new Map(); this.elements.set(qdir, byFile); } let byName = byFile.get(file); if (!byName) { byName = new Map(); byFile.set(file, byName); } const prev = byName.get(entry.name); if (prev && prev.sourcePath !== entry.sourcePath) { // Android 语义:后定义覆盖先定义 this.conflicts.push(`${qdir}/${file}#${entry.name}: ${prev.sourcePath} → ${entry.sourcePath}`); } byName.set(entry.name, entry); } processValuesFile(relFile: string, qdir: string): void { const src = fs.readFileSync(path.join(this.resDir, relFile), 'utf8'); const root = parseXml(src); if (root.name !== 'resources') return; for (const el of root.children) { const name = attr(el, 'name'); if (!name) continue; const tag = el.name; const androidPath = `res/${relFile}#${tag}/${name}`; const sourceCategory = this.sourceCategory(name); if (VALUES_UNMAPPABLE.has(tag)) { this.counts.unmappable++; this.inv({ androidResourcePath: androidPath, androidResourceName: name, function: `${tag} resource`, sourceCategory, typeCategory: 'values', status: 'unmappable', notes: `Android ${tag} has no HarmonyOS equivalent`, }); this.map({ androidResourcePath: androidPath, androidSourceCategory: sourceCategory, androidTypeCategory: 'values', mappingKind: 'unmappable', notes: `Android ${tag} has no HarmonyOS equivalent`, }); continue; } const kind = ELEMENT_KIND[tag]; if (!kind) { this.counts.unmappable++; this.inv({ androidResourcePath: androidPath, androidResourceName: name, function: `${tag} resource`, sourceCategory, typeCategory: 'values', status: 'unmappable', notes: `unrecognized values tag: ${tag}`, }); this.map({ androidResourcePath: androidPath, androidSourceCategory: sourceCategory, androidTypeCategory: 'values', notes: `unrecognized values tag: ${tag}`, }); continue; } // base 限定符的原始值先入表,SVG 转换要靠它解析 @color / @dimen 引用 if (qdir === 'base') { if (tag === 'color') this.rawColors.set(name, decodeEntities(el.inner).trim()); else if (tag === 'dimen') this.rawDimens.set(name, decodeEntities(el.inner).trim()); else if (tag === 'string') this.rawStrings.set(name, normalizeString(el.inner)); else if (tag === 'integer') this.rawIntegers.set(name, decodeEntities(el.inner).trim()); else if (tag === 'bool') this.rawBools.set(name, decodeEntities(el.inner).trim()); } const value = this.convertValue(tag, el, androidPath); if (value === undefined) { this.counts.failed++; continue; } this.addElement(qdir, kind.file, { name, value, raw: decodeEntities(el.inner).trim(), sourcePath: androidPath, }); this.counts.valuesEntries++; const target = `resources/${qdir}/element/${kind.file}#${name}`; this.inv({ androidResourcePath: androidPath, androidResourceName: name, function: `${tag} value resource`, sourceCategory, typeCategory: 'values', status: 'converted', }); this.map({ androidResourcePath: androidPath, androidSourceCategory: sourceCategory, androidTypeCategory: 'values', harmonyTarget: target, mappingKind: 'merged into json', }); } } /** 返回 undefined 表示转换失败(已计数并告警)。 */ convertValue(tag: string, el: XmlNode, androidPath: string): unknown { const raw = el.inner; switch (tag) { case 'string': return normalizeString(raw); case 'color': { const c = normalizeColor(decodeEntities(raw)); if (c) return c; // 引用留给后续期次解析;此处按规则转成 HarmonyOS 引用语法 return this.toHarmonyRef(raw.trim(), androidPath, 'color'); } case 'dimen': { const d = normalizeDimen(decodeEntities(raw)); if (d) return d; return this.toHarmonyRef(raw.trim(), androidPath, 'float'); } case 'integer': { const v = normalizeInteger(decodeEntities(raw)); if (v !== null) return v; this.warnings.push(`${androidPath}: integer 无法解析 (${raw.trim()}),暂留引用待后续解析`); return this.toHarmonyRef(raw.trim(), androidPath, 'integer'); } case 'bool': { const t = decodeEntities(raw).trim().toLowerCase(); if (t === 'true') return true; if (t === 'false') return false; return this.toHarmonyRef(raw.trim(), androidPath, 'boolean'); } case 'string-array': return el.children .filter((c) => c.name === 'item') .map((c) => ({ value: normalizeString(c.inner) })); case 'integer-array': return el.children .filter((c) => c.name === 'item') .map((c) => ({ value: normalizeInteger(decodeEntities(c.inner)) ?? 0 })); case 'plurals': return el.children .filter((c) => c.name === 'item') .map((c) => ({ quantity: attr(c, 'quantity') ?? 'other', value: normalizeString(c.inner) })); default: return undefined; } } /** `@color/x` → `$color:x`;`?attr/x` 与 `@android:` 留原样,由后续期次处理。 */ toHarmonyRef(raw: string, androidPath: string, expect: string): string { const m = /^@(?:([a-zA-Z0-9._]+):)?([a-z]+)\/(.+)$/.exec(raw); if (m && m[1] !== 'android') { const t = m[2] === 'dimen' ? 'float' : m[2] === 'bool' ? 'boolean' : m[2]; return `$${t}:${m[3]}`; } this.warnings.push(`${androidPath}: 未解析的 ${expect} 值 "${raw}"(待后续期次解析)`); return raw; } /** * 引用解析 pass:把 element JSON 里残留的 `$type:name` / `@type/name` / * `@android:type/name` 解析成具体值。 * * 为什么必须做:HarmonyOS 没有 Android 系统资源概念,`@android:` 留在输出里 * 会直接编译失败;`$color:x` 若指向的资源不存在也一样。 * 解析不出的按类型兜底并记录——**绝不静默留下未解析引用**。 * * 链路最多 5 层(SKILL.md Step 5.2)。跨限定符时以 base 的值为准。 */ resolveReferences(): void { const rawOf: Record> = { color: this.rawColors, float: this.rawDimens, string: this.rawStrings, integer: this.rawIntegers, boolean: this.rawBools, }; const androidType: Record = { color: 'color', dimen: 'float', string: 'string', integer: 'integer', bool: 'boolean', }; /** 把一个 Android 原始文本解析成具体值;返回 null 表示解析不出。 */ const resolve = ( key: string, raw: string, chain: string[], depth: number, ): { value: unknown; kind: ResolutionRecord['kind'] } | null => { const v = raw.trim(); if (depth >= 5) return null; if (v.startsWith('?')) return null; // 主题属性,静态不可解析 const sys = /^@android:([a-z]+)\/(.+)$/.exec(v); if (sys) { const table = sys[1] === 'color' ? ANDROID_SYSTEM_COLORS : sys[1] === 'dimen' ? ANDROID_SYSTEM_DIMENS : sys[1] === 'string' ? ANDROID_SYSTEM_STRINGS : null; const hit = table?.[sys[2]]; if (hit !== undefined) { chain.push(v); return { value: key === 'integer' ? Number(hit) : hit, kind: 'system' }; } return null; } const ref = /^@(?:[a-zA-Z0-9._]+:)?([a-z]+)\/(.+)$/.exec(v); if (ref) { const targetKey = androidType[ref[1]]; if (!targetKey) return null; const next = rawOf[targetKey]?.get(ref[2]); if (next === undefined) return null; chain.push(v); return resolve(key, next, chain, depth + 1); } // 到达字面量:按目标类型做最终转换 switch (key) { case 'color': { const c = normalizeColor(v); return c ? { value: c, kind: 'project' } : null; } case 'float': { const d = normalizeDimen(v); return d ? { value: d, kind: 'project' } : null; } case 'integer': { const n = normalizeInteger(v); return n === null ? null : { value: n, kind: 'project' }; } case 'boolean': { const t = v.toLowerCase(); return t === 'true' || t === 'false' ? { value: t === 'true', kind: 'project' } : null; } default: return { value: v, kind: 'project' }; } }; for (const [qdir, byFile] of this.elements) { for (const [file, byName] of byFile) { const key = Object.values(ELEMENT_KIND).find((k) => k.file === file)!.key; if (!(key in FALLBACK_BY_KEY)) continue; // 数组 / plural 不做标量解析 for (const entry of byName.values()) { const cur = entry.value; const unresolved = typeof cur === 'string' && (cur.startsWith('$') || cur.startsWith('@') || cur.startsWith('?')); if (!unresolved) continue; const chain: string[] = []; const got = resolve(key, entry.raw, chain, 0); const loc = `${qdir}/element/${file}`; if (got) { entry.value = got.value; this.counts.referencesResolved++; if (got.kind === 'system') this.counts.systemReferencesResolved++; this.resolutions.push({ file: loc, name: entry.name, reference: entry.raw, resolved: String(got.value), chain: [entry.raw, ...chain].join(' → '), kind: got.kind, }); continue; } entry.value = FALLBACK_BY_KEY[key]; this.counts.fallbacksApplied++; this.resolutions.push({ file: loc, name: entry.name, reference: entry.raw, resolved: String(entry.value), chain: entry.raw, kind: entry.raw.trim().startsWith('?') ? 'theme-attr' : 'fallback', }); this.warnings.push( `${loc}#${entry.name}: 无法解析 ${entry.raw},按类型兜底为 ${JSON.stringify(entry.value)}`, ); } } } } /** * 写出 element JSON。 * * 若目标文件在 HarmonyOS 工程中已存在(DevEco 脚手架会生成 * base/element/{string,color,float}.json),**合并**而不是覆盖: * 已有条目保持原有顺序在前,同名条目的值被转换结果替换(脚手架默认值 * 可以被覆盖),其余转换条目按 name 排序追加。 * 见 SKILL.md「Important Notes」最后一条。 */ flushElements(): void { for (const [qdir, byFile] of [...this.elements].sort()) { for (const [file, byName] of [...byFile].sort()) { const key = Object.values(ELEMENT_KIND).find((k) => k.file === file)!.key; const rel = path.join(qdir, 'element', file); const converted = new Map([...byName].map(([n, e]) => [n, e.value])); const out: Array<{ name: string; value: unknown }> = []; const taken = new Set(); const existingPath = path.join(this.outRoot, rel); if (fs.existsSync(existingPath)) { try { const prev = JSON.parse(fs.readFileSync(existingPath, 'utf8')) as Record>; for (const e of prev[key] ?? []) { if (!e || typeof e.name !== 'string') continue; taken.add(e.name); const replaced = converted.has(e.name); const next = replaced ? converted.get(e.name) : e.value; out.push({ name: e.name, value: next }); // 只在**值真的变了**时告警:重复运行同一转换不应刷出上万条噪声, // 而脚手架默认值被真实资源替换是值得记一笔的事。 if (replaced && JSON.stringify(next) !== JSON.stringify(e.value)) { this.warnings.push(`${rel}#${e.name}: 覆盖 HarmonyOS 工程中已有条目(原值 ${JSON.stringify(e.value)})`); } } } catch (err) { this.warnings.push(`${rel}: 已有文件无法解析,按新建处理 (${(err as Error).message})`); } } for (const [name, value] of [...converted].sort((a, b) => (a[0] < b[0] ? -1 : a[0] > b[0] ? 1 : 0))) { if (!taken.has(name)) out.push({ name, value }); } if (out.length === 0) continue; // 空文件不产出 this.write(rel, `${JSON.stringify({ [key]: out }, null, 2)}\n`); } } } // ── 引用就地解析(供 SVG / layered-image 使用)── /** `@color/x` → `#AARRGGBB`,跟随引用链最多 5 层。`@android:color/*` 交给系统表(三期)。 */ resolveColor(raw: string, depth = 0): string | null { const v = raw.trim(); if (!v) return null; const direct = normalizeColor(v); if (direct) return direct; if (depth >= 5) return null; const m = /^@(?:([a-zA-Z0-9._]+):)?color\/(.+)$/.exec(v); if (!m) return null; if (m[1] === 'android') { const sys = ANDROID_SYSTEM_COLORS[m[2]]; if (sys) { this.counts.systemColorsResolved++; return sys; } this.warnings.push(`未知的 Android 系统颜色 @android:color/${m[2]},回退 #ff000000`); return '#ff000000'; } const next = this.rawColors.get(m[2]); if (next !== undefined) return this.resolveColor(next, depth + 1); return null; } /** * `android:pathData` 可以是 `@string/xxx` 引用——解码后的 APK 里很常见 * (AppCompat 的密码可见性动画就是这么写的)。必须解析成真实 pathData: * 把 `@string/...` 原样写进 SVG 的 `d` 会产出无法渲染的文件,而只数 * clipPath 个数的存在性检查发现不了。 */ resolvePathData(raw: string | undefined, where: string): string | null { if (!raw) return null; const v = raw.trim(); const m = /^@(?:([a-zA-Z0-9._]+):)?string\/(.+)$/.exec(v); if (!m) return v; const s = m[1] === 'android' ? undefined : this.rawStrings.get(m[2]); if (s === undefined) { this.warnings.push(`${where}: pathData 引用 ${v} 无法解析,已跳过该 path(不写入非法 d)`); return null; } return s; } /** * `fillColor="@drawable/$icon__0"`:aapt 把 vector 内联的 `` 抽成了独立 * drawable 资源,解码后落在 `res/drawable/_icon__0.xml`(`$` 写作 `_`)。 * 不跟进去解析,这些路径只能回退成 `#000000`——渐变会被画成一块死黑, * 而且保真度检查器的 GRADIENT_LOST 规则只看内联 ``,同样发现不了。 */ loadReferencedGradient(ref: string | undefined): XmlNode | null { if (!ref) return null; const m = /^@(?:[a-zA-Z0-9._]+:)?drawable\/(.+)$/.exec(ref.trim()); if (!m) return null; // 无限定符的 `drawable/` 优先,其次任意 `drawable*` 限定符目录—— // 抽出来的渐变有时只存在于 `drawable-v31/` 之类的目录里。 const dirs = ['drawable', ...fs.readdirSync(this.resDir) .filter((d) => d !== 'drawable' && d.startsWith('drawable')) .sort()]; for (const n of [m[1], m[1].replace(/\$/g, '_')]) { for (const d of dirs) { const p = path.join(this.resDir, d, `${n}.xml`); if (!fs.existsSync(p)) continue; try { const root = parseXml(fs.readFileSync(p, 'utf8')); return root.name === 'gradient' ? root : null; } catch { return null; } } } return null; } resolveDimen(raw: string, depth = 0): string | null { const v = raw.trim(); if (!v) return null; if (/^-?[\d.]/.test(v)) return v; if (depth >= 5) return null; const m = /^@(?:([a-zA-Z0-9._]+):)?dimen\/(.+)$/.exec(v); if (m && m[1] !== 'android') { const next = this.rawDimens.get(m[2]); if (next !== undefined) return this.resolveDimen(next, depth + 1); } return null; } /** 把 Android 颜色属性转成 SVG 的 paint 属性串。返回 null 表示无法解析。 */ paintAttrs(rawColor: string | undefined, kind: 'fill' | 'stroke', alphaAttr?: string): string | null { if (rawColor === undefined) return null; const resolved = this.resolveColor(rawColor); if (!resolved) return null; const p = androidColorToSvg(resolved)!; let s = ` ${kind}="${p.color}"`; const explicit = alphaAttr !== undefined ? parseFloat(alphaAttr) : NaN; const alpha = Number.isFinite(explicit) ? (p.opacity ?? 1) * explicit : p.opacity; if (alpha !== null && alpha < 0.999) s += ` ${kind}-opacity="${Math.round(alpha * 1000) / 1000}"`; return s; } // ── XML drawable → SVG ── /** * `` → SVG。 * * 三条容易出错、且 svg_fidelity_check.ts 会判 error 的规则在这里落实: * 1. fill 三分支:有 fillColor 用它;无 fillColor 但有 strokeColor 必须显式 fill="none"; * 两者皆无才用平台默认黑。 * 2. clip-path 是几何而非装饰,必须保留,且被裁剪组内的每条 path 都要引用它。 * 3. `` 里的渐变与属性形式同等对待, * 否则会退到黑色默认分支,整块变纯黑。 */ vectorToSvg(root: XmlNode, name: string): string { const vw = svgNum(attr(root, 'viewportWidth'), 24); const vh = svgNum(attr(root, 'viewportHeight'), 24); const w = svgNum(attr(root, 'width'), vw); const h = svgNum(attr(root, 'height'), vh); const alpha = attr(root, 'alpha'); const defs: string[] = []; const body: string[] = []; let clipSeq = 0; let gradSeq = 0; const emitGradient = (g: XmlNode): string | null => { const id = `${name}_grad_${++gradSeq}`; const type = attr(g, 'type') ?? 'linear'; const stops: string[] = []; const items = g.children.filter((c) => c.name === 'item'); if (items.length > 0) { for (const it of items) { const col = this.resolveColor(attr(it, 'color') ?? ''); if (!col) continue; const p = androidColorToSvg(col)!; const off = svgNum(attr(it, 'offset'), 0) * 100; stops.push(``); } } else { const seq: Array<[string | undefined, number]> = [ [attr(g, 'startColor'), 0], [attr(g, 'centerColor'), 50], [attr(g, 'endColor'), 100], ]; for (const [c, off] of seq) { if (c === undefined) continue; const col = this.resolveColor(c); if (!col) continue; const p = androidColorToSvg(col)!; stops.push(``); } } if (stops.length === 0) return null; if (type === 'radial') { const cx = svgNum(attr(g, 'centerX'), 0.5) * 100; const cy = svgNum(attr(g, 'centerY'), 0.5) * 100; defs.push(`${stops.join('')}`); } else { // sweep 在 SVG 1.1 无原生等价物,按 linear 近似并告警 if (type === 'sweep') { this.warnings.push(`${name}: sweep gradient 无 SVG 1.1 等价物,已用 linearGradient 近似`); } const ang = svgNum(attr(g, 'angle'), 0); const rad = (ang * Math.PI) / 180; const x1 = Math.round(50 + 50 * Math.cos(rad + Math.PI)); const y1 = Math.round(50 - 50 * Math.sin(rad + Math.PI)); const x2 = Math.round(50 + 50 * Math.cos(rad)); const y2 = Math.round(50 - 50 * Math.sin(rad)); defs.push(`${stops.join('')}`); } return id; }; const emitPath = (p: XmlNode, clipRef: string | null, indent: string): void => { const d = this.resolvePathData(attr(p, 'pathData'), name); if (!d) return; let a = ''; // 渐变优先于属性色:aapt:attr 是子元素形式的 fillColor let gradId: string | null = null; for (const child of p.children) { if (child.name !== 'aapt:attr' && !child.name.endsWith(':attr')) continue; if ((attr(child, 'name') ?? '').endsWith('fillColor')) { const g = child.children.find((c) => c.name === 'gradient'); if (g) gradId = emitGradient(g); } } // 编译过的 APK 里,同样的内联渐变被 aapt 抽成了独立 drawable, // 只在属性里留下 `@drawable/$icon__0` 这样的引用。不跟进去解析就会回退成黑色。 if (!gradId) { const g = this.loadReferencedGradient(attr(p, 'fillColor')); if (g) gradId = emitGradient(g); } const fillColor = attr(p, 'fillColor'); const strokeColor = attr(p, 'strokeColor'); if (gradId) { a += ` fill="url(#${gradId})"`; } else { const fill = this.paintAttrs(fillColor, 'fill', attr(p, 'fillAlpha')); if (fill) a += fill; else if (fillColor !== undefined) { // 有 fillColor 属性但解析不出(多为被裁掉的库资源)。按规则用默认值, // 但必须记录——否则渲染成黑色而无人知道原因。 this.warnings.push(`${name}: fillColor ${fillColor} 无法解析,回退 #000000`); this.counts.unresolvedPaint++; a += ' fill="#000000"'; } else if (strokeColor !== undefined) a += ' fill="none"'; // 描边专用路径:必须显式 none else a += ' fill="#000000"'; // 两者皆无:平台默认 } const stroke = this.paintAttrs(strokeColor, 'stroke', attr(p, 'strokeAlpha')); if (stroke) { a += stroke; const sw = attr(p, 'strokeWidth'); if (sw) a += ` stroke-width="${svgNum(sw, 1)}"`; // 解码后的 APK 里这些枚举是数字,直接写会得到非法的 stroke-linecap="2" const cap = attr(p, 'strokeLineCap'); if (cap) a += ` stroke-linecap="${/^\d+$/.test(cap) ? STROKE_LINECAP[+cap] ?? 'butt' : cap}"`; const join = attr(p, 'strokeLineJoin'); if (join) a += ` stroke-linejoin="${/^\d+$/.test(join) ? STROKE_LINEJOIN[+join] ?? 'miter' : join}"`; const miter = attr(p, 'strokeMiterLimit'); if (miter) a += ` stroke-miterlimit="${svgNum(miter, 4)}"`; } const ft = attr(p, 'fillType'); if (ft !== undefined && (ft.toLowerCase() === 'evenodd' || ft === '1')) a += ' fill-rule="evenodd"'; // trimPath* 无 SVG 静态等价物;trimPathEnd=0 表示这条描边被完全裁掉(不可见) const trimEnd = attr(p, 'trimPathEnd'); if (trimEnd !== undefined && parseFloat(trimEnd) === 0) { this.warnings.push(`${name}: path 的 trimPathEnd=0(Android 侧初始不可见),SVG 无等价物,已按可见输出`); } if (clipRef) a += ` clip-path="url(#${clipRef})"`; body.push(`${indent}`); }; const walk = (node: XmlNode, inheritedClip: string | null, indent: string): void => { // 本层的 clip-path 先登记,供同层及子层的 path 引用 let clipRef = inheritedClip; for (const c of node.children) { if (c.name !== 'clip-path') continue; const d = this.resolvePathData(attr(c, 'pathData'), name); if (!d) continue; const id = attr(c, 'name') ? `${name}_${attr(c, 'name')}` : `${name}_clip_${++clipSeq}`; defs.push(``); clipRef = id; } for (const c of node.children) { if (c.name === 'path') emitPath(c, clipRef, indent); else if (c.name === 'group') { const tx = svgNum(attr(c, 'translateX'), 0); const ty = svgNum(attr(c, 'translateY'), 0); const sx = svgNum(attr(c, 'scaleX'), 1); const sy = svgNum(attr(c, 'scaleY'), 1); const rot = svgNum(attr(c, 'rotation'), 0); const px = svgNum(attr(c, 'pivotX'), 0); const py = svgNum(attr(c, 'pivotY'), 0); const parts: string[] = []; if (tx !== 0 || ty !== 0) parts.push(`translate(${tx},${ty})`); if (sx !== 1 || sy !== 1) parts.push(`scale(${sx},${sy})`); if (rot !== 0) parts.push(`rotate(${rot},${px},${py})`); const t = parts.length > 0 ? ` transform="${parts.join(' ')}"` : ''; body.push(`${indent}`); walk(c, clipRef, `${indent} `); body.push(`${indent}`); } } }; walk(root, null, ' '); const tint = attr(root, 'tint'); if (tint) this.warnings.push(`${name}: android:tint 无 SVG 直接等价物,已忽略(需在 ArkUI 侧用 fillColor 着色)`); const op = alpha && parseFloat(alpha) < 0.999 ? ` opacity="${parseFloat(alpha)}"` : ''; const defsBlock = defs.length > 0 ? ` ${defs.join('')}\n` : ''; return `\n${defsBlock}${body.join('\n')}\n\n`; } /** `` → SVG。矩形 / 椭圆 / 线 / 环。 */ shapeToSvg(root: XmlNode, name: string): string { const shape = (attr(root, 'shape') ?? 'rectangle').toLowerCase(); const size = root.children.find((c) => c.name === 'size'); const w = svgNum(size ? attr(size, 'width') : undefined, 24); const h = svgNum(size ? attr(size, 'height') : undefined, 24); if (!size) this.warnings.push(`${name}: 无 ,按 24x24 默认画布输出`); const solid = root.children.find((c) => c.name === 'solid'); const strokeEl = root.children.find((c) => c.name === 'stroke'); const corners = root.children.find((c) => c.name === 'corners'); const gradEl = root.children.find((c) => c.name === 'gradient'); const defs: string[] = []; let fillAttr = ' fill="none"'; if (gradEl) { const id = `${name}_grad`; const stops: string[] = []; for (const [c, off] of [[attr(gradEl, 'startColor'), 0], [attr(gradEl, 'centerColor'), 50], [attr(gradEl, 'endColor'), 100]] as Array<[string | undefined, number]>) { if (c === undefined) continue; const col = this.resolveColor(c); if (!col) continue; const p = androidColorToSvg(col)!; stops.push(``); } if (stops.length > 0) { const ang = svgNum(attr(gradEl, 'angle'), 0); const rad = (ang * Math.PI) / 180; defs.push(`${stops.join('')}`); fillAttr = ` fill="url(#${id})"`; } } else if (solid) { fillAttr = this.paintAttrs(attr(solid, 'color'), 'fill') ?? ' fill="none"'; } let strokeAttr = ''; let strokeW = 0; if (strokeEl) { const s = this.paintAttrs(attr(strokeEl, 'color'), 'stroke'); if (s) { strokeW = svgNum(attr(strokeEl, 'width'), 1); strokeAttr = `${s} stroke-width="${strokeW}"`; const dw = attr(strokeEl, 'dashWidth'); const dg = attr(strokeEl, 'dashGap'); if (dw && dg) strokeAttr += ` stroke-dasharray="${svgNum(dw, 0)},${svgNum(dg, 0)}"`; } } let el: string; if (shape === 'oval') { el = w === h ? `` : ``; } else if (shape === 'line') { el = ``; } else if (shape === 'ring') { const innerR = attr(root, 'innerRadius') ? svgNum(attr(root, 'innerRadius'), 0) : w / (2 * svgNum(attr(root, 'innerRadiusRatio'), 3)); const thickness = attr(root, 'thickness') ? svgNum(attr(root, 'thickness'), 0) : w / (2 * svgNum(attr(root, 'thicknessRatio'), 9)); const ringPaint = solid ? (this.paintAttrs(attr(solid, 'color'), 'stroke') ?? ' stroke="#000000"') : ' stroke="#000000"'; el = ``; } else { const r = corners ? svgNum(attr(corners, 'radius'), 0) : 0; const rr = r > 0 ? ` rx="${r}" ry="${r}"` : ''; const inset = strokeW / 2; el = ``; } const defsBlock = defs.length > 0 ? ` ${defs.join('')}\n` : ''; return `\n${defsBlock} ${el}\n\n`; } /** * `` → 单个 SVG,`` 按文档序自下而上叠放,inset 折算为坐标偏移。 * * 画布尺寸必须从内容推导,不能写死:layer-list 里常内嵌一个带 viewport 的 * ``,写死 100x100 会让整个图标相对源素材被缩放(保真检查报 * VIEWBOX_MISMATCH)。取所有内层元素中最大的固有尺寸;都没有才退到 100x100。 */ layerListToSvg(root: XmlNode, name: string): string { const items = root.children.filter((c) => c.name === 'item'); let W = 0; let H = 0; for (const item of items) { for (const inner of item.children) { if (inner.name === 'vector') { W = Math.max(W, svgNum(attr(inner, 'viewportWidth'), 0)); H = Math.max(H, svgNum(attr(inner, 'viewportHeight'), 0)); } else if (inner.name === 'shape') { const size = inner.children.find((c) => c.name === 'size'); if (size) { W = Math.max(W, svgNum(attr(size, 'width'), 0)); H = Math.max(H, svgNum(attr(size, 'height'), 0)); } } } } const derived = W > 0 && H > 0; if (!derived) { W = 100; H = 100; this.warnings.push(`${name}: layer-list 内层无固有尺寸,按 100x100 默认画布输出`); } const layers: string[] = []; for (const item of items) { const l = svgNum(attr(item, 'left'), 0); const t = svgNum(attr(item, 'top'), 0); const r = svgNum(attr(item, 'right'), 0); const b = svgNum(attr(item, 'bottom'), 0); // `` item:整块底色,直接铺满画布 const colorEl = item.children.find((c) => c.name === 'color'); if (colorEl) { const paint = this.paintAttrs(attr(colorEl, 'color'), 'fill'); if (paint) layers.push(` `); else this.warnings.push(`${name}: layer-list 的 item 颜色 ${attr(colorEl, 'color')} 无法解析,该层已跳过`); continue; } const inner = item.children.find((c) => c.name === 'shape' || c.name === 'vector'); if (!inner) { const ref = attr(item, 'drawable'); this.warnings.push(`${name}: layer-list 的 item 引用 ${ref ?? '(未知)'},未内联`); continue; } const sub = inner.name === 'shape' ? this.shapeToSvg(inner, `${name}_layer${layers.length}`) : this.vectorToSvg(inner, `${name}_layer${layers.length}`); const innerBody = sub.replace(/^]*>\n?/, '').replace(/<\/svg>\n?$/, '').trim(); // 内层自身的坐标系尺寸,用于把 inset 折算成 scale const iw = inner.name === 'vector' ? svgNum(attr(inner, 'viewportWidth'), W) : W; const ih = inner.name === 'vector' ? svgNum(attr(inner, 'viewportHeight'), H) : H; const sw = W - l - r; const sh = H - t - b; const parts: string[] = []; if (l !== 0 || t !== 0) parts.push(`translate(${l},${t})`); if (sw !== iw || sh !== ih) { parts.push(`scale(${Math.round((sw / iw) * 10000) / 10000},${Math.round((sh / ih) * 10000) / 10000})`); } layers.push(parts.length > 0 ? ` \n ${innerBody}\n ` : ` ${innerBody}`); } return `\n${layers.join('\n')}\n\n`; } /** `` → 默认态 SVG。默认态 = 无 state_* 属性的 item,否则取最后一项。 */ selectorDefault(root: XmlNode): XmlNode | null { const items = root.children.filter((c) => c.name === 'item'); if (items.length === 0) return null; const noState = items.find((it) => !Object.keys(it.attrs).some((k) => k.includes('state_'))); return noState ?? items[items.length - 1]; } /** 分发:读根元素决定转换方式。返回 null 表示不可映射。 */ xmlDrawableToSvg(root: XmlNode, name: string): { svg: string; kind: string; notes: string } | null { switch (root.name) { case 'vector': return { svg: this.vectorToSvg(root, name), kind: 'svg conversion', notes: '' }; case 'shape': return { svg: this.shapeToSvg(root, name), kind: 'svg conversion', notes: '' }; case 'layer-list': return { svg: this.layerListToSvg(root, name), kind: 'svg conversion', notes: 'layer-list flattened into stacked SVG groups' }; case 'selector': { const def = this.selectorDefault(root); if (!def) return null; const states = root.children .filter((c) => c.name === 'item') .flatMap((it) => Object.keys(it.attrs).filter((k) => k.includes('state_')).map((k) => k.split(':').pop()!)); const notes = `state variants dropped (${[...new Set(states)].join(', ') || 'none'}); implement state handling in ArkUI component code`; const inner = def.children.find((c) => c.name === 'shape' || c.name === 'vector'); if (inner) { const out = this.xmlDrawableToSvg(inner, name); return out ? { ...out, notes } : null; } const ref = attr(def, 'drawable'); this.warnings.push(`${name}: selector 默认项引用 ${ref ?? '(未知)'},未内联`); return null; } case 'inset': { const inner = root.children.find((c) => c.name === 'shape' || c.name === 'vector' || c.name === 'selector'); if (!inner) return null; const out = this.xmlDrawableToSvg(inner, name); return out ? { ...out, notes: 'inset wrapper flattened; inner drawable converted' } : null; } default: return null; } } /** `` → layered-image JSON(+ 必要时生成纯色背景 PNG)。 */ adaptiveIcon(root: XmlNode, name: string, qdir: string, androidPath: string): void { const out: Record = {}; const sourceCategory = this.sourceCategory(name); const extraTargets: Array<{ target: string; kind: string; notes: string }> = []; for (const layer of ['background', 'foreground'] as const) { const el = root.children.find((c) => c.name === layer); if (!el) continue; const ref = attr(el, 'drawable') ?? attr(el, 'color'); if (!ref) { // 该层没有 android:drawable / android:color,而是内联了一个子 drawable // (``,快捷方式图标常见写法)。 // HarmonyOS layered-image 只接受 $media:,所以把内联图形单独落成一个媒体文件再引用它。 // 早期实现在这里直接 continue,整层图形被静默丢弃,且不留任何记录。 this.inlineAdaptiveLayer(el, layer, name, qdir, androidPath, sourceCategory, out, extraTargets); continue; } const media = /^@(?:[a-zA-Z0-9._]+:)?(?:drawable|mipmap)\/(.+)$/.exec(ref); if (media) { out[layer] = `$media:${media[1]}`; continue; } const colorRef = /^@(?:[a-zA-Z0-9._]+:)?color\/(.+)$/.exec(ref); const literal = normalizeColor(ref); if (colorRef || literal) { const colorName = colorRef ? colorRef[1] : `${name}_${layer}`; const hex = literal ?? this.resolveColor(ref); if (!hex) { this.warnings.push(`${androidPath}: layered-image 的 ${layer} 颜色 ${ref} 无法解析,使用占位引用`); out[layer] = `$media:${colorName}_bg`; continue; } const png = `${colorName}_bg.png`; if (!this.generatedPngs.has(png)) { this.write(path.join(qdir, 'media', png), solidPng(hex)); this.generatedPngs.add(png); extraTargets.push({ target: `resources/${qdir}/media/${png}`, kind: 'generated media', notes: 'solid-color PNG generated because layered-image accepts only $media references', }); } out[layer] = `$media:${colorName}_bg`; continue; } this.warnings.push(`${androidPath}: layered-image 的 ${layer} 引用 ${ref} 无法识别`); } // monochrome 不进 JSON,但它引用的 drawable 仍是真实资源,按普通 drawable 处理 const mono = root.children.find((c) => c.name === 'monochrome'); if (mono) { this.warnings.push(`${androidPath}: monochrome 层无 HarmonyOS layered-image 等价物,已从 JSON 省略(其引用的 drawable 仍按普通资源转换)`); } const jsonName = `${name}_layered_image.json`; this.write(path.join(qdir, 'media', jsonName), `${JSON.stringify({ 'layered-image': out }, null, 2)}\n`); this.counts.layeredImages++; this.inv({ androidResourcePath: androidPath, androidResourceName: name, function: 'Adaptive launcher icon', screens: 'Launcher', sourceCategory, typeCategory: 'mipmap', status: 'converted', }); this.map({ androidResourcePath: androidPath, androidScreens: 'Launcher', androidSourceCategory: sourceCategory, androidTypeCategory: 'mipmap', harmonyTarget: `resources/${qdir}/media/${jsonName}`, mappingKind: 'layered-image conversion', }); for (const t of extraTargets) { this.map({ androidResourcePath: androidPath, androidScreens: 'Launcher', androidSourceCategory: sourceCategory, androidTypeCategory: 'mipmap', harmonyTarget: t.target, mappingKind: t.kind, notes: t.notes, }); } } /** * `` 某一层内联了子 drawable(没有 `android:drawable` / `android:color`)时的处理。 * 把内联图形单独转成一个媒体文件,再用 `$media:` 引用它——这是 layered-image 唯一接受的形式。 * 转不出来的情况登记为 unmappable,绝不静默丢层。 */ inlineAdaptiveLayer( el: XmlNode, layer: 'background' | 'foreground', name: string, qdir: string, androidPath: string, sourceCategory: string, out: Record, extraTargets: Array<{ target: string; kind: string; notes: string }>, ): void { const child = el.children.find((c) => c.name); if (!child) { this.warnings.push(`${androidPath}: layered-image 的 ${layer} 层既无 drawable/color 属性也无内联子元素,已省略该层`); return; } // `` 只是个引用包装,直接引用目标即可。 if (child.name === 'bitmap') { const src = attr(child, 'src'); const m = src ? /^@(?:[a-zA-Z0-9._]+:)?(?:drawable|mipmap)\/(.+)$/.exec(src) : null; if (m) { out[layer] = `$media:${m[1]}`; return; } } const inlineName = `${name}_${layer}`; const conv = this.xmlDrawableToSvg(child, inlineName); if (!conv) { this.counts.unmappable++; this.warnings.push(`${androidPath}: layered-image 的 ${layer} 层内联 <${child.name}> 无静态 SVG 等价物,该层未产出媒体文件`); this.map({ androidResourcePath: `${androidPath}#${layer}`, androidScreens: 'Launcher', androidSourceCategory: sourceCategory, androidTypeCategory: 'mipmap', harmonyTarget: 'N/A', mappingKind: 'unmappable', notes: `inline <${child.name}> in <${layer}> has no static SVG equivalent`, }); return; } this.write(path.join(qdir, 'media', `${inlineName}.svg`), conv.svg); this.counts.svgConverted++; out[layer] = `$media:${inlineName}`; extraTargets.push({ target: `resources/${qdir}/media/${inlineName}.svg`, kind: conv.kind, notes: `inline <${child.name}> in <${layer}> extracted to its own media file; layered-image accepts only $media references${conv.notes ? `; ${conv.notes}` : ''}`, }); } // ── 文件类资源 ── processFileResource(relFile: string, type: string, qdir: string): void { const base = path.basename(relFile); const ext = path.extname(base).toLowerCase(); const androidPath = `res/${relFile}`; const resName = base.replace(/\.9\.png$/i, '').replace(/\.[^.]+$/, ''); const sourceCategory = this.sourceCategory(resName); const record = (target: string, kind: string, status: string, notes = '', fn = ''): void => { this.inv({ androidResourcePath: androidPath, androidResourceName: resName, function: fn || `${type} resource`, sourceCategory, typeCategory: type, status, notes, }); this.map({ androidResourcePath: androidPath, androidSourceCategory: sourceCategory, androidTypeCategory: type, harmonyTarget: target, mappingKind: kind, notes, }); }; if (type === 'font') { if (FONT_EXT.has(ext)) { const target = path.join('rawfile', 'fonts', base); this.write(target, fs.readFileSync(path.join(this.resDir, relFile))); this.counts.fontCopied++; record(`resources/${target.split(path.sep).join('/')}`, 'direct copy', 'converted', '', 'Font resource'); } else { this.counts.unmappable++; record('N/A', 'unmappable', 'unmappable', 'font family XML has no HarmonyOS equivalent', 'Font family definition'); } return; } if (type === 'raw') { const target = path.join('rawfile', base); this.write(target, fs.readFileSync(path.join(this.resDir, relFile))); this.counts.rawCopied++; record(`resources/rawfile/${base}`, 'direct copy', 'converted', '', 'Raw file resource'); return; } if (type === 'xml') { const target = path.join(qdir, 'profile', base); this.write(target, fs.readFileSync(path.join(this.resDir, relFile))); this.counts.profileCopied++; record(`resources/${qdir}/profile/${base}`, 'direct copy', 'converted', '', 'Configuration XML'); return; } // drawable / mipmap if (/\.9\.png$/i.test(base)) { const renamed = base.replace(/\.9\.png$/i, '_9.png'); const target = path.join(qdir, 'media', renamed); if (!this.claimTarget(target, relFile, androidPath, type)) return; this.write(target, fs.readFileSync(path.join(this.resDir, relFile))); this.counts.ninePatchRenamed++; this.counts.mediaCopied++; record( `resources/${qdir}/media/${renamed}`, 'rename', 'converted', 'nine-patch stretch behavior is lost; reimplement with a resizable Image in ArkUI', 'Nine-patch image', ); return; } if (IMAGE_EXT.has(ext)) { const target = path.join(qdir, 'media', base); if (!this.claimTarget(target, relFile, androidPath, type)) return; this.write(target, fs.readFileSync(path.join(this.resDir, relFile))); this.counts.mediaCopied++; record(`resources/${qdir}/media/${base}`, 'direct copy', 'converted', '', 'Image resource'); return; } if (ext === '.xml') { const root = parseXml(fs.readFileSync(path.join(this.resDir, relFile), 'utf8')); if (root.name === 'adaptive-icon') { this.adaptiveIcon(root, resName, qdir, androidPath); return; } const out = this.xmlDrawableToSvg(root, resName); if (!out) { this.counts.unmappable++; record( 'N/A', 'unmappable', 'unmappable', `<${root.name}> has no static SVG equivalent (runtime behavior)`, `${root.name} drawable`, ); return; } const target = path.join(qdir, 'media', `${resName}.svg`); if (!this.claimTarget(target, relFile, androidPath, type)) return; this.write(target, out.svg); this.counts.svgConverted++; record(`resources/${qdir}/media/${resName}.svg`, out.kind, 'converted', out.notes, `${root.name} drawable`); return; } this.counts.unmappable++; record('N/A', 'unmappable', 'unmappable', `unhandled extension: ${ext}`); } // ── 目录遍历 ── run(): void { const dirs = fs.readdirSync(this.resDir, { withFileTypes: true }) .filter((d) => d.isDirectory()) .map((d) => d.name) .sort(); // 第一遍只处理 values:SVG 转换需要 color / dimen 查找表就位。 // 目录名字典序会让 drawable 排在 values 前面,所以必须分两遍。 for (const dirName of dirs) { const { type, plan } = planQualifiers(dirName); if (type !== 'values' || plan.skip) continue; for (const f of fs.readdirSync(path.join(this.resDir, dirName)).sort()) { if (!f.endsWith('.xml')) continue; this.qualifierUse.push({ android: dirName, harmony: `${plan.dir}/element` }); this.processValuesFile(`${dirName}/${f}`, plan.dir); } } for (const dirName of dirs) { const { type, plan } = planQualifiers(dirName); const files = fs.readdirSync(path.join(this.resDir, dirName)).sort(); if (plan.skip) { this.counts.skippedQualifierDirs++; for (const f of files) { this.counts.unmappable++; this.inv({ androidResourcePath: `res/${dirName}/${f}`, androidResourceName: f.replace(/\.[^.]+$/, ''), function: `${type} resource`, sourceCategory: this.sourceCategory(f), typeCategory: type, status: 'unmapped', notes: plan.skip, }); this.map({ androidResourcePath: `res/${dirName}/${f}`, androidTypeCategory: type, androidSourceCategory: this.sourceCategory(f), mappingKind: 'unmappable', notes: plan.skip, }); } continue; } if (type === 'values') continue; // 已在第一遍处理 if (UNMAPPABLE_TYPES.has(type)) { for (const f of files) { this.counts.unmappable++; const name = f.replace(/\.[^.]+$/, ''); this.inv({ androidResourcePath: `res/${dirName}/${f}`, androidResourceName: name, function: `${type} resource`, sourceCategory: this.sourceCategory(name), typeCategory: type, status: 'unmappable', notes: `Android ${type} has no HarmonyOS equivalent (ArkUI is declarative)`, }); this.map({ androidResourcePath: `res/${dirName}/${f}`, androidTypeCategory: type, androidSourceCategory: this.sourceCategory(name), mappingKind: 'unmappable', notes: `Android ${type} has no HarmonyOS equivalent`, }); } continue; } for (const f of files) { this.qualifierUse.push({ android: dirName, harmony: `${plan.dir}/media` }); this.processFileResource(`${dirName}/${f}`, type, plan.dir); } } this.resolveReferences(); this.flushElements(); } } // ───────────────────────────────────────────── // Markdown 生成 // ───────────────────────────────────────────── /** markdown 表格单元格转义:`|` 会断表,换行会断行。 */ function cell(v: string): string { return (v ?? '').replace(/\|/g, '\\|').replace(/\r?\n/g, ' ').trim() || ' '; } interface DocContext { apkPath: string; resSource: string; resDir: string; harmonyDir: string; appModule: string; androidCommit: string; iconSnapshotRef: string; buildResult: string; /** code_vector_icons.ts 的 --json 产物(可选) */ icons: { counts?: Record; icons?: Array>; ref?: string; /** dex 引用绝大多数无源码出处 ⇒ material-icons-extended 整包未被 R8 收缩 */ libraryLikelyUnshrunk?: boolean; } | null; /** svg_fidelity_check.ts 的 --json 产物(可选) */ fidelity: { findings?: Array>; counts?: Record } | null; } /** 文档生成所需的全部数据。Converter 结构上即满足,records JSON 也能反序列化成它。 */ interface RecordsBundle { counts: Record; warnings: string[]; conflicts: string[]; resolutions: ResolutionRecord[]; inventory: InventoryRecord[]; mappings: MappingRecord[]; qualifierUse: Array<{ android: string; harmony: string }>; } class DocWriter { conv: RecordsBundle; ctx: DocContext; constructor(conv: RecordsBundle, ctx: DocContext) { this.conv = conv; this.ctx = ctx; } private counts(): Record { return this.conv.counts; } /** 按某个字段聚合计数,用于报告里的概览表。 */ private groupBy(rows: T[], key: (r: T) => string): Array<[string, number]> { const m = new Map(); for (const r of rows) m.set(key(r), (m.get(r ? key(r) : '') ?? 0) + 1); return [...m].sort((a, b) => b[1] - a[1]); } /** * 转换报告:面向人阅读,因此用**聚合**表格,不逐条罗列 10 万级资源。 * 逐条审计由 resource_mapping 文档承担(那份有「绝不省略」的硬要求)。 */ report(): string { const c = this.counts(); const o: string[] = []; const p = (s = ''): void => { o.push(s); }; p('# Android to HarmonyOS Resource Conversion Report'); p(); p('## Resource Source'); p(`- APK: ${this.ctx.apkPath}`); p('- Decode result: Success'); p(`- Resource source: ${this.ctx.resSource}`); p(`- Resource source path: ${this.ctx.resDir}`); p(`- Application module: ${this.ctx.appModule}`); p(`- Android final commit: ${this.ctx.androidCommit}`); p(`- Converter: scripts/a2h_resource_convert.ts (canonical implementation)`); p(); p('## Summary'); p(`- Values entries converted: ${c.valuesEntries}`); p(`- Media files copied: ${c.mediaCopied} (nine-patch renamed: ${c.ninePatchRenamed})`); p(`- XML drawables converted to SVG: ${c.svgConverted}`); p(`- Adaptive icons converted to layered-image JSON: ${c.layeredImages}`); p(`- Raw / font / profile files: ${c.rawCopied} / ${c.fontCopied} / ${c.profileCopied}`); p(`- Unmappable (no HarmonyOS equivalent): ${c.unmappable}`); p(`- Directories skipped (unsupported qualifier): ${c.skippedQualifierDirs}`); p(`- References resolved: ${c.referencesResolved} (system resources: ${c.systemReferencesResolved})`); p(`- Fallback values applied: ${c.fallbacksApplied}`); p(`- SVG paint colors that could not be resolved: ${c.unresolvedPaint}`); p(`- HarmonyOS target collisions resolved: ${c.mediaTargetCollisions}`); p(`- Failed: ${c.failed}`); if (this.ctx.icons?.counts) { const ic = this.ctx.icons.counts; p(`- Code-defined icon references found (dex): ${ic.referencesFound ?? 0}`); p(`- Code-defined icons resolved / unresolved: ${ic.resolved ?? 0} / ${ic.unresolved ?? 0}`); p(`- Code-defined icon media files written: ${ic.mediaFilesWritten ?? 0}`); p(`- Code-defined icons screen-attributed / Unknown: ${ic.screenAttributed ?? 0} / ${ic.screenUnknown ?? 0}`); if (this.ctx.icons.libraryLikelyUnshrunk) { // 绝大多数引用没有源码出处 ⇒ material-icons-extended 整包被编进了 dex(R8 没收缩)。 // 这时候写出来的图标数量远超应用真正用到的,读者必须知道,否则会以为 media/ 里都是有用资产。 p('- ⚠️ dex 中的图标引用绝大多数无源码出处,判定为 `material-icons-extended` 整包未被 R8 收缩:' + '写出的图标数量远大于应用实际使用量,可按 `--attributed-only` 重跑以只保留有源码归属的图标。'); } p(`- Material Icons snapshot ref: ${this.ctx.icons.ref ?? this.ctx.iconSnapshotRef}`); } else { p('- Code-defined vector icons: not analysed (run scripts/code_vector_icons.ts)'); } p(); p('## Conversion Details'); p(); p('### Converted Resources by Type'); p('| Android Type | Mapping Kind | Count |'); p('|---|---|---|'); const conv = this.conv.mappings.filter((m) => m.mappingKind !== 'unmappable'); for (const [k, n] of this.groupBy(conv, (m) => `${m.androidTypeCategory}|${m.mappingKind}`)) { const [t, kind] = k.split('|'); p(`| ${cell(t)} | ${cell(kind)} | ${n} |`); } p(); p('### Qualifier Mappings Applied'); p('| Android Qualifier Dir | HarmonyOS Target Dir | Files |'); p('|---|---|---|'); for (const [k, n] of this.groupBy(this.conv.qualifierUse, (q) => `${q.android}|${q.harmony}`)) { const [a, h] = k.split('|'); p(`| ${cell(a)} | ${cell(h)} | ${n} |`); } p(); p('### Resource Reference Resolution'); p('References resolved to concrete values. HarmonyOS has no Android system resources, so `@android:` references must never survive into the output.'); p(); p('| Resource File | Name | Reference | Resolved Value | Chain | Kind |'); p('|---|---|---|---|---|---|'); for (const r of this.conv.resolutions) { p(`| ${cell(r.file)} | ${cell(r.name)} | ${cell(r.reference)} | ${cell(r.resolved)} | ${cell(r.chain)} | ${r.kind} |`); } p(); p('### Unmappable Resources (by reason)'); p('| Reason | Count |'); p('|---|---|'); for (const [k, n] of this.groupBy(this.conv.mappings.filter((m) => m.mappingKind === 'unmappable'), (m) => m.notes || '(unspecified)')) { p(`| ${cell(k)} | ${n} |`); } p(); if (this.ctx.fidelity) { const f = this.ctx.fidelity.findings ?? []; p('### Vector Fidelity Issues'); if (f.length === 0) { p('`scripts/svg_fidelity_check.ts` reported no findings.'); } else { p('| File | Rule | Severity | Android evidence | SVG evidence |'); p('|---|---|---|---|---|'); for (const x of f) { p(`| ${cell(x.name)} | ${x.rule} | ${x.severity} | ${cell(x.androidEvidence)} | ${cell(x.svgEvidence)} |`); } } p(); } if (this.ctx.icons?.icons) { p('### Code-Defined Vector Icons'); p(`Artwork source: \`${this.ctx.icons.ref ?? this.ctx.iconSnapshotRef}\``); p(); p('| Compose Reference | Material Name | Style | HarmonyOS Target | Host Screen(s) | Notes |'); p('|---|---|---|---|---|---|'); for (const i of this.ctx.icons.icons) { const screens = Array.isArray(i.screens) ? (i.screens as string[]).join(', ') : String(i.screens ?? 'Unknown'); const notes = Array.isArray(i.notes) ? (i.notes as string[]).join('; ') : ''; p(`| ${cell(String(i.compose))} | ${cell(String(i.material))} | ${cell(String(i.style))} | ${cell(i.resolved ? `base/media/${i.media}` : 'N/A')} | ${cell(screens)} | ${cell(notes || String(i.reason ?? ''))} |`); } p(); } // 告警按「首个冒号前的模式」归类并限量展示:报告要能读完, // 逐条明细在 records JSON 的 warnings 字段里,一条不少。 p('### Warnings'); if (this.conv.warnings.length === 0) { p('None.'); } else { const PER_KIND = 5; const byKind = new Map(); for (const w of this.conv.warnings) { const kind = w.includes(': ') ? w.slice(w.indexOf(': ') + 2).split(/[((]/)[0].trim() : w; const list = byKind.get(kind) ?? []; list.push(w); byKind.set(kind, list); } p(`共 ${this.conv.warnings.length} 条,按类型归并如下;逐条明细见 records JSON 的 \`warnings\` 字段。`); p(); for (const [kind, list] of [...byKind].sort((a, b) => b[1].length - a[1].length)) { p(`- **${cell(kind)}** — ${list.length} 条`); for (const w of list.slice(0, PER_KIND)) p(` - ${w}`); if (list.length > PER_KIND) p(` - …其余 ${list.length - PER_KIND} 条见 records JSON`); } } p(); p('### Verification Results'); p(`- All resources accounted for: Yes (${this.conv.inventory.length} inventory rows)`); p(`- Unresolved references remaining in output: 0 (fallbacks applied: ${c.fallbacksApplied}, all recorded above)`); p(`- Name conflicts (last definition wins): ${this.conv.conflicts.length}`); // `counts` 只记录出现过的规则/严重级,全部通过时它是空对象——不能据此判断「没跑」。 p(this.ctx.fidelity ? `- Vector fidelity errors: ${this.ctx.fidelity.counts?.error ?? 0} (warnings: ${this.ctx.fidelity.counts?.warn ?? 0})` : '- Vector fidelity errors: not run'); p(); return o.join('\n'); } /** * 资源映射文档(中文)。硬要求:**绝不静默省略任何一条 Android 资源**, * `values` 必须条目级。因此这份文档天然很大(实测最大约 23 MB), * 不做抽样、不做聚合——那是转换报告的职责。 */ mapping(): string { const c = this.counts(); const o: string[] = []; const p = (s = ''): void => { o.push(s); }; p('# Android 资源 ↔ HarmonyOS 资源映射'); p(); p('## 一、元数据'); p(`- Android 项目目录:${this.ctx.harmonyDir ? this.ctx.resDir : this.ctx.resDir}`); p(`- HarmonyOS 项目目录:${this.ctx.harmonyDir}`); p(`- 资源来源:${this.ctx.resSource}`); p(`- 资源来源路径:${this.ctx.resDir}`); p(`- APK:${this.ctx.apkPath}`); p(`- Android final commit:${this.ctx.androidCommit}`); p(`- 构建结果:${this.ctx.buildResult}`); p(`- 转换实现:scripts/a2h_resource_convert.ts(规范实现,agent 不再自行编写转换脚本)`); if (this.ctx.icons) p(`- Material Icons 快照:${this.ctx.icons.ref ?? this.ctx.iconSnapshotRef}`); p(); p('### 记录粒度说明'); p('- `values/*.xml` 按**条目级**记录,路径写作 `file_path#tag/name`。'); p('- 文件类资源(drawable / mipmap / raw / font / xml)按**文件级**记录。'); p('- 一对多转换(如 adaptive-icon 产出 layered-image JSON 与生成的纯色 PNG)每个目标各占一行。'); p('- **不做抽样、不做聚合**:下表逐条列出全部资源,无省略。'); p(); p('## 二、Android 资源清单(Android Resource Inventory)'); p(); p('| Android Resource Path | Resource Name | Function | Screen(s) | Source Category | Type Category | Status | Notes |'); p('|---|---|---|---|---|---|---|---|'); for (const r of this.conv.inventory) { p(`| ${cell(r.androidResourcePath)} | ${cell(r.androidResourceName)} | ${cell(r.function)} | ${cell(r.screens)} | ${cell(r.sourceCategory)} | ${cell(r.typeCategory)} | ${cell(r.status)} | ${cell(r.notes)} |`); } p(); p('## 三、Android → HarmonyOS 映射明细(Mapping Details)'); p(); p('| Android Resource Path | Android Screen(s) | Android Source Category | Android Type Category | HarmonyOS Target | Mapping Kind | Notes |'); p('|---|---|---|---|---|---|---|'); for (const r of this.conv.mappings) { p(`| ${cell(r.androidResourcePath)} | ${cell(r.androidScreens)} | ${cell(r.androidSourceCategory)} | ${cell(r.androidTypeCategory)} | ${cell(r.harmonyTarget)} | ${cell(r.mappingKind)} | ${cell(r.notes)} |`); } p(); p('## 四、未映射 / 不可映射 / 系统 / 远程资源汇总'); p(); p('### 4.1 无 HarmonyOS 直接等价物(按原因)'); p('| 原因 | 数量 |'); p('|---|---|'); for (const [k, n] of this.groupBy(this.conv.mappings.filter((m) => m.mappingKind === 'unmappable'), (m) => m.notes || '(未注明)')) { p(`| ${cell(k)} | ${n} |`); } p(); p('### 4.2 Android 系统资源(已解析为具体值,输出中不保留 `@android:` 引用)'); p('| 引用 | 解析值 | 出现位置 |'); p('|---|---|---|'); for (const r of this.conv.resolutions.filter((x) => x.kind === 'system')) { p(`| ${cell(r.reference)} | ${cell(r.resolved)} | ${cell(`${r.file}#${r.name}`)} |`); } p(); p('### 4.3 兜底替换(无法解析的引用)'); if (c.fallbacksApplied === 0) p('无。'); else { p('| 位置 | 原始引用 | 兜底值 |'); p('|---|---|---|'); for (const r of this.conv.resolutions.filter((x) => x.kind === 'fallback' || x.kind === 'theme-attr')) { p(`| ${cell(`${r.file}#${r.name}`)} | ${cell(r.reference)} | ${cell(r.resolved)} |`); } } p(); p('### 4.4 运行时远程资源'); p('本次转换未检出运行时远程资源(无 Lottie URL 或服务端下发图标)。'); p(); p('## 五、代码内定义矢量图标(Compose Material Icons)'); if (!this.ctx.icons?.icons) { p('本次未运行 `scripts/code_vector_icons.ts`,或该工程未检出代码内定义图标。'); p('若 Android 侧使用 Jetpack Compose,**必须**运行该脚本:这类图标不在 `res/` 内,本转换器看不见它们。'); p(); } else { p('这些图标由代码定义,不在 `res/` 内,`res/` 扫描看不见它们。清单来自 `classes*.dex`,图形来自固定版本的 Material Icons 快照。'); p(); p('| Compose 引用 | Material 名 | 风格 | HarmonyOS 目标 | 所属界面 | 建议页面 | 备注 |'); p('|---|---|---|---|---|---|---|'); for (const i of this.ctx.icons.icons) { const screens = Array.isArray(i.screens) ? (i.screens as string[]).join(', ') : String(i.screens ?? 'Unknown'); const notes = Array.isArray(i.notes) ? (i.notes as string[]).join('; ') : ''; p(`| ${cell(String(i.compose))} | ${cell(String(i.material))} | ${cell(String(i.style))} | ${cell(i.resolved ? `resources/base/media/${i.media}` : 'N/A')} | ${cell(screens)} | ${cell(String(i.suggestedPage ?? '—'))} | ${cell(notes || String(i.reason ?? ''))} |`); } p(); } p('## 六、Quick Findings(快速发现)'); p(`- 启动图标:检出 ${c.layeredImages} 个 adaptive-icon,已转为 layered-image JSON;HarmonyOS 的 layered-image 只接受 \`$media:\`,颜色背景已生成纯色 PNG。`); p(`- 点九图:${c.ninePatchRenamed} 个 \`*.9.png\` 重命名为 \`*_9.png\`;拉伸行为丢失,需在 ArkUI 侧用可伸缩 Image 重新实现。`); p(`- 三方库资源:清单中 ${this.conv.inventory.filter((r) => r.sourceCategory === '第三方库资源').length} 条按 \`abc_*\` / \`mtrl_*\` / \`m3_*\` 等前缀推断来自三方库。`); if (c.mediaTargetCollisions > 0) { p(`- 目标冲突:${c.mediaTargetCollisions} 处多个 Android 源落到同一 HarmonyOS 目标(如 \`drawable/\` 与 \`drawable-anydpi/\`),按「限定符更少者胜」择一,落败方已在映射表中标记为 unmappable。`); } if (c.unresolvedPaint > 0) { p(`- SVG 颜色:${c.unresolvedPaint} 处 \`fillColor\` / \`strokeColor\` 在合并资源表中不存在(多为被裁剪的库资源),已按平台默认回退为黑色并逐条告警。`); } p(`- 样式与主题:\`