Sign In

@double-coding/pixel-print

Package Overview
Dependencies
Maintainers
2
Versions
12
Alerts
File Explorer

Advanced tools

Socket logo

Install Socket

Detect and block malicious and high-risk dependencies

Install

@double-coding/pixel-print - npm Package Compare versions

Comparing version
1.2.7
to
1.3.0
+632
templates/skills/pp-d2c-reskin/reskin-slice.mjs
#!/usr/bin/env node
// reskin-slice.mjs — pp-d2c-reskin skill 主脚本
//
// 定位:完全独立的切图 skill,只依赖 Node 18+ 内置能力(fetch),不 spawn 任何兄弟 skill 的脚本。
//
// 两种工作模式:
// 1. 有基线(--base <url> 或读到 .d2c-cache/last-page.json):按基线切图清单去每套 --theme 稿子
// 找同名节点切图,报 miss;文件名与基线对齐,便于业务代码写 themeKey→dir 映射。
// 2. 无基线(standalone):每套 --theme 独立扫自己图层树,前缀命中就切,不做跨稿匹配。
//
// 前缀规则(与 pp-d2c §4 图层前缀体系对齐):
// - img / img-* → 整层导出 PNG
// - bg / bg-* → 背景图 PNG
// - 裸标签 img / bg 用父节点 name 辅助命名(sub-hero-card > bg → hero-card__bg.png)
// - 匹配去重按 <parent>||<name> 复合 key(裸标签 + 带子名统一走此规则)
// - 同名带子名(如 3 个 img-icon 分处不同父)自动加父路径 slug 前缀区分文件名,不再静默丢图
//
// 依赖: pp-d2c.config.json(读 images.assetsDir)、.env FIGMA_TOKEN、Node 18+
import fs from 'node:fs'
import path from 'node:path'
import { setTimeout as sleep } from 'node:timers/promises'
const CWD = process.cwd()
const FIGMA_API = 'https://api.figma.com'
const MAX_RETRIES = 3
// ─── util ───────────────────────────────────────────────────────
function die(msg, code = 1) {
console.error(`[pp-d2c-reskin] ${msg}`)
process.exit(code)
}
function findProjectRoot(startDir = CWD) {
let dir = path.resolve(startDir)
while (true) {
if (fs.existsSync(path.join(dir, 'pp-d2c.config.json'))) return dir
const parent = path.dirname(dir)
if (parent === dir) return null
dir = parent
}
}
function ensureDir(p) {
if (!fs.existsSync(p)) fs.mkdirSync(p, { recursive: true })
}
// slug: 用于 --theme name (子目录名 + shell 参数),严格 ASCII 化保稳
// 空串兜底为 'theme',主要用于命令行参数场景;文件名场景另用 slugForFilename
function slugify(s) {
return String(s)
.toLowerCase()
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-+|-+$/g, '') || 'theme'
}
// 图片文件名 slug: 保留 CJK 中日韩表意文字,避免纯中文图层名全部撞成同一个 slug
// 现代 FS / Metro / Webpack / RN require 都能吃中文文件名,不做 ASCII 化
// 空串兜底传入 fallback(通常是 nodeId 冒号形式,不撞车)
function slugForFilename(s, fallback) {
const cleaned = String(s)
.toLowerCase()
.replace(/[^a-z0-9一-鿿]+/g, '-')
.replace(/^-+|-+$/g, '')
return cleaned || fallback
}
// 极简 .env 解析:KEY=VALUE,支持引号和 # 注释,不做变量插值
function parseEnvFile(text) {
const out = {}
for (const rawLine of text.split(/\r?\n/)) {
const line = rawLine.trim()
if (!line || line.startsWith('#')) continue
const m = line.match(/^([A-Za-z_][A-Za-z0-9_]*)\s*=\s*(.*)$/)
if (!m) continue
let val = m[2]
if ((val.startsWith('"') && val.endsWith('"')) || (val.startsWith("'") && val.endsWith("'"))) {
val = val.slice(1, -1)
}
out[m[1]] = val
}
return out
}
// token 读取:process.env > 项目根 .env > 兜底 config.figma.token(老项目兼容)
function loadFigmaToken(projectRoot, config) {
if (process.env.FIGMA_TOKEN) return process.env.FIGMA_TOKEN
const envPath = path.join(projectRoot, '.env')
if (fs.existsSync(envPath)) {
try {
const parsed = parseEnvFile(fs.readFileSync(envPath, 'utf8'))
if (parsed.FIGMA_TOKEN) return parsed.FIGMA_TOKEN
} catch {}
}
return config.figma?.token || null
}
// figma URL 形态:https://www.figma.com/design/<fileKey>/<name>?node-id=<a>-<b>
function parseFigmaUrl(u) {
try {
const url = new URL(u)
const m = url.pathname.match(/\/(design|file)\/([A-Za-z0-9]+)/)
if (!m) return null
const fileKey = m[2]
const rawNode = url.searchParams.get('node-id')
if (!rawNode) return { fileKey, nodeId: null }
// URL 里 node-id 用 - 分隔(如 138-2050),API 里用 : 分隔(138:2050)
const nodeId = rawNode.includes(':') ? rawNode : rawNode.replace(/-/, ':')
return { fileKey, nodeId }
} catch {
return null
}
}
function parseArgs(argv) {
// dedupeSiblings 默认 false:同父下同名节点(auto-layout 循环卡片)全部切出;
// 打开 → 同 <parent>||<name> 只切第一个,兼容极少数"循环项刻意重复,切一次即可"场景
//
// outManifest (v1.1.0 pp-d2c Step 1.5 契约): 写切图清单到指定路径,格式:
// { generatedAt, mode, themes: [{ slug, entries: [{ nodeId, name, parentName, filename, filepath, sliceWidth?, sliceHeight? }] }] }
const out = { themes: [], dryRun: false, base: null, prefixes: ['img', 'bg'], dedupeSiblings: false, outManifest: null }
for (let i = 0; i < argv.length; i++) {
const a = argv[i]
if (a === '--dry-run' || a === '-n') { out.dryRun = true; continue }
if (a === '--dedupe-siblings') { out.dedupeSiblings = true; continue }
if (a === '--base') { out.base = argv[++i]; continue }
if (a === '--out-manifest') { out.outManifest = argv[++i]; continue }
if (a === '--prefix') {
out.prefixes = argv[++i].split(',').map(p => p.replace(/-+$/, ''))
continue
}
if (a === '--theme') {
const raw = argv[++i]
const eq = raw.indexOf('=')
if (eq < 0) die(`--theme 参数格式错: ${raw},应为 <name>=<figmaUrl>`)
const name = raw.slice(0, eq)
const url = raw.slice(eq + 1)
out.themes.push({ name, slug: slugify(name), url })
}
}
return out
}
// ─── Figma REST 封装(内嵌,不 spawn) ─────────────────────────
async function figmaFetch(pathAndQuery, token) {
let lastErr
for (let i = 0; i < MAX_RETRIES; i++) {
try {
const res = await fetch(`${FIGMA_API}${pathAndQuery}`, {
headers: { 'X-Figma-Token': token },
})
if (res.status === 403 || res.status === 401) {
throw new Error(`Figma API auth failed (HTTP ${res.status}); token invalid, expired, or lacks permission`)
}
if (!res.ok) {
const body = await res.text()
throw new Error(`Figma API error HTTP ${res.status}: ${body.slice(0, 200)}`)
}
const json = await res.json()
if (json.err) throw new Error(`Figma API returned err: ${json.err}`)
return json
} catch (e) {
lastErr = e
if (i < MAX_RETRIES - 1) await sleep(Math.pow(2, i) * 1000)
}
}
throw lastErr
}
async function downloadToFile(url, destPath) {
let lastErr
for (let i = 0; i < MAX_RETRIES; i++) {
try {
const res = await fetch(url)
if (!res.ok) throw new Error(`download HTTP ${res.status}`)
const buf = Buffer.from(await res.arrayBuffer())
ensureDir(path.dirname(destPath))
fs.writeFileSync(destPath, buf)
return
} catch (e) {
lastErr = e
if (i < MAX_RETRIES - 1) await sleep(Math.pow(2, i) * 1000)
}
}
throw lastErr
}
// 拉一个 frame 的子树 JSON
async function fetchNodeTree(fileKey, nodeId, token) {
const q = new URLSearchParams({ ids: nodeId })
const resp = await figmaFetch(`/v1/files/${fileKey}/nodes?${q.toString()}`, token)
const doc = resp.nodes?.[nodeId]?.document
if (!doc) throw new Error(`Figma /v1/files/${fileKey}/nodes 未返回 ${nodeId} 的 document`)
return doc
}
// 单节点导出为 PNG,直接落到指定路径
async function exportImageToPath(fileKey, nodeId, token, destPath, scale = 2) {
const q = new URLSearchParams({
ids: nodeId,
format: 'png',
scale: String(scale),
use_absolute_bounds: 'true',
})
const resp = await figmaFetch(`/v1/images/${fileKey}?${q.toString()}`, token)
const url = resp.images?.[nodeId]
if (!url) throw new Error(`Figma /v1/images 未返回 ${nodeId} 的 URL`)
await downloadToFile(url, destPath)
}
// 读 PNG 前 24 字节的 IHDR chunk 拿宽高 (纯 Node fs, 无第三方依赖)
// 用于 v1.1.0 bg 溢出检测: 断言导出的 png 尺寸 ≈ node.absoluteBoundingBox * scale
function readPngDimensions(pngPath) {
const fd = fs.openSync(pngPath, 'r')
try {
const buf = Buffer.alloc(24)
fs.readSync(fd, buf, 0, 24, 0)
// PNG signature: 89 50 4E 47 0D 0A 1A 0A + IHDR chunk (length[4] "IHDR" width[4] height[4])
if (buf[0] !== 0x89 || buf[1] !== 0x50 || buf[2] !== 0x4e || buf[3] !== 0x47) {
throw new Error('not a PNG file')
}
const width = buf.readUInt32BE(16)
const height = buf.readUInt32BE(20)
return { width, height }
} finally {
fs.closeSync(fd)
}
}
// 断言 png 尺寸 ≈ node bbox * scale (容差 4px 覆盖亚像素舍入)
// 返回 null = 通过; 返回 string = 违规原因
function assertPngSize(pngPath, node, scale) {
if (!node || !node.absoluteBoundingBox) return null
const bb = node.absoluteBoundingBox
const expectedW = Math.round(bb.width * scale)
const expectedH = Math.round(bb.height * scale)
let actual
try {
actual = readPngDimensions(pngPath)
} catch (e) {
return `PNG 读取失败: ${e.message}`
}
const dx = Math.abs(actual.width - expectedW)
const dy = Math.abs(actual.height - expectedH)
const TOL = 4
if (dx > TOL || dy > TOL) {
return `png ${actual.width}x${actual.height} 与 node bbox ${expectedW}x${expectedH} 相差 dx=${dx} dy=${dy} (兄弟节点溢出到 renderBounds?)`
}
return null
}
// ─── 前缀匹配 + 图层遍历 ─────────────────────────────────────
function isSliceName(name, prefixes) {
for (const p of prefixes) {
if (name === p) return true
if (name.startsWith(p + '-')) return true
}
return false
}
function collectSliceNodes(node, prefixes, out = [], pathStack = [], parentName = null) {
if (!node) return out
const name = node.name || ''
if (isSliceName(name, prefixes)) {
// renderBounds 是 Figma 出图真实裁剪范围(含描边/投影/子元素溢出);
// boundingBox 是名义框,遇 mask/clip 时会锁死 → 排查 PNG 尺寸不符预期时看前者
const renderBounds = node.absoluteRenderBounds || null
const boundingBox = node.absoluteBoundingBox || null
out.push({
id: node.id,
name,
parentName,
pathStack: [...pathStack, name],
renderBounds,
boundingBox,
})
}
if (Array.isArray(node.children)) {
for (const child of node.children) {
collectSliceNodes(child, prefixes, out, [...pathStack, name], name)
}
}
return out
}
// 基础文件名(不带父路径前缀,可能与其他节点撞):裸标签借父 name 拼,带子名去前缀 slug 化
// 冲突消解在 resolveFilenameCollisions() 里统一做,这里只出"意图名"
function baseFilename(name, parentName, nodeId) {
const idSafe = String(nodeId || '').replace(/:/g, '_') || 'node'
if (name === 'img' || name === 'bg') {
const parentStripped = parentName
? parentName.replace(/^(img|bg|sub|block|scrollx|scrolly|fixed|end|btn|input|x)-/, '')
: 'root'
const parentSlug = slugForFilename(parentStripped, idSafe)
return `${parentSlug}__${name}`
}
const stripped = name.replace(/^(img|bg)-/, '')
return slugForFilename(stripped, idSafe)
}
// 收 pathStack 里除自身外最近的一层祖先 name 做前缀(跳过通用组名"编组"/"Group"及 slice 名自身)
// 目的:img-icon × 3 分处不同父 Frame → frame-722__icon / frame-726__icon / frame-730__icon
function parentPrefixSlug(pathStack, nodeId) {
const idSafe = String(nodeId || '').replace(/:/g, '_') || 'node'
const GENERIC = new Set(['编组', 'group', 'frame'])
// pathStack 最后一项是自身 name,倒数第二项才是父;继续往上找到第一个非通用名
for (let i = pathStack.length - 2; i >= 0; i--) {
const raw = String(pathStack[i] || '').toLowerCase().trim()
if (!raw) continue
if (GENERIC.has(raw)) continue
// "Frame 722" 这种带数字的具体名 → 保留;"编组 6" / "Group 12" → 也保留(数字给了区分度)
const slug = slugForFilename(pathStack[i], idSafe)
if (slug && slug !== idSafe) return slug
}
return idSafe // 全是通用名兜底用 nodeId
}
// 冲突消解:同 basename 的 slice 依次加父路径前缀,极端撞名的兜底 nodeId
// 输入 slices 已经过 matchKey 去重(auto-layout 循环项之类),同 basename 只可能是"真的不同父路径下的同名"
function resolveFilenameCollisions(slices) {
const byBase = new Map()
for (const s of slices) {
const base = baseFilename(s.name, s.parentName, s.id)
if (!byBase.has(base)) byBase.set(base, [])
byBase.get(base).push(s)
}
for (const [base, group] of byBase) {
if (group.length === 1) {
group[0].filename = base
continue
}
// 撞车:每个都加父路径前缀
const usedNames = new Map()
for (const s of group) {
const prefix = parentPrefixSlug(s.pathStack, s.id)
let candidate = `${prefix}__${base}`
// 极少数二次撞名(两个父路径 slug 又相同)→ nodeId 兜底
if (usedNames.has(candidate)) {
candidate = `${prefix}__${base}__${String(s.id).replace(/:/g, '_')}`
}
usedNames.set(candidate, true)
s.filename = candidate
}
}
return slices
}
// matchKey:恒用 <parent>||<name> 复合 key
// - 裸标签沿用旧语义
// - 带子名图层(img-icon / bg-card)也用父辅助 → 3 个同名 img-icon 分处不同父就是 3 个不同 key,都保留
// - aligned-to-base 模式下换肤稿若父 Frame 改名会 miss,这是应有的严格性(父路径漂移=对不齐)
function matchKey(name, parentName) {
return `${parentName || 'root'}||${name}`
}
// ─── 主流程 ────────────────────────────────────────────────────
async function main() {
const args = parseArgs(process.argv.slice(2))
const projectRoot = findProjectRoot()
if (!projectRoot) die('未找到 pp-d2c.config.json,请先在项目根跑 pp-d2c init')
const config = JSON.parse(fs.readFileSync(path.join(projectRoot, 'pp-d2c.config.json'), 'utf8'))
const assetsDir = (config.images?.assetsDir || 'static/').replace(/^\//, '')
const token = loadFigmaToken(projectRoot, config)
if (!token) die('FIGMA_TOKEN 未配置,请在项目根 .env 写 FIGMA_TOKEN=xxx')
if (args.themes.length === 0 && !args.dryRun) {
die('未传 --theme <name>=<figmaUrl>;至少传一套稿子,或加 --dry-run 只扫基线')
}
// ─── 基线来源:--base > last-page.json > 无(standalone) ─────
let baseFileKey = null, baseNodeId = null, baseSource = null
if (args.base) {
const parsed = parseFigmaUrl(args.base)
if (!parsed || !parsed.nodeId) die('--base 必须是含 node-id 的完整 figma URL')
baseFileKey = parsed.fileKey
baseNodeId = parsed.nodeId
baseSource = '--base'
} else {
const lastPagePath = path.join(projectRoot, '.d2c-cache/last-page.json')
if (fs.existsSync(lastPagePath)) {
try {
const lp = JSON.parse(fs.readFileSync(lastPagePath, 'utf8'))
if (lp.fileKey && (lp.rootNodeId || lp.nodeId)) {
baseFileKey = lp.fileKey
baseNodeId = lp.rootNodeId || lp.nodeId
baseSource = 'last-page.json'
}
} catch {}
}
}
const hasBase = !!(baseFileKey && baseNodeId)
console.log(`[pp-d2c-reskin] projectRoot : ${projectRoot}`)
console.log(`[pp-d2c-reskin] assetsDir : ${assetsDir}`)
console.log(`[pp-d2c-reskin] base : ${hasBase ? `${baseFileKey} / ${baseNodeId} (${baseSource})` : '无(standalone 模式,每套稿子独立切)'}`)
console.log(`[pp-d2c-reskin] prefixes : ${args.prefixes.join(' ')}`)
console.log(`[pp-d2c-reskin] themes : ${args.themes.length} 套`)
console.log(`[pp-d2c-reskin] mode : ${args.dryRun ? 'dry-run' : 'export'}`)
console.log('')
// ─── 有基线:先拉基线子树 → 生成切图清单 ─────────────────
let uniqSlices = null
if (hasBase) {
console.log('[pp-d2c-reskin] 拉基线子树...')
const baseDoc = await fetchNodeTree(baseFileKey, baseNodeId, token)
const sliceList = collectSliceNodes(baseDoc, args.prefixes)
// 同父下同名去重(默认不做,--dedupe-siblings 开启)。跨父同名恒保留(不同 matchKey)
let dedupedList = sliceList
if (args.dedupeSiblings) {
const seenKeys = new Set()
dedupedList = []
for (const s of sliceList) {
const k = matchKey(s.name, s.parentName)
if (seenKeys.has(k)) continue
seenKeys.add(k)
s._matchKey = k
dedupedList.push(s)
}
} else {
// 不 dedup 也要挂 _matchKey,给下游对齐用(此时同 matchKey 的多项会共存)
for (const s of sliceList) s._matchKey = matchKey(s.name, s.parentName)
}
// 冲突消解:同 basename 加父路径前缀,再次撞名兜底 nodeId
uniqSlices = resolveFilenameCollisions(dedupedList)
console.log(`[pp-d2c-reskin] 基线切图清单:${uniqSlices.length} 项(扫描到 ${sliceList.length}, dedupe-siblings=${args.dedupeSiblings ? 'on' : 'off'})`)
for (const s of uniqSlices) {
const rb = s.renderBounds
const rTag = rb ? `render=${Math.round(rb.width)}x${Math.round(rb.height)}` : 'render=?'
console.log(` · ${s.name} → ${s.filename}.png (nodeId=${s.id}, ${rTag})`)
}
console.log('')
}
if (args.dryRun) {
console.log(hasBase
? '[pp-d2c-reskin] dry-run 完成,未拉换肤稿'
: '[pp-d2c-reskin] dry-run + 无基线:什么都没扫,请传 --theme 或 --base 或 --dry-run --base=<url>')
return
}
// ─── 逐套稿子:切图 + 归子目录 ─────────────────────────
const reports = []
for (const theme of args.themes) {
console.log(`\n[pp-d2c-reskin] === theme: ${theme.name} (slug=${theme.slug}) ===`)
const parsed = parseFigmaUrl(theme.url)
if (!parsed || !parsed.nodeId) {
console.log(` × URL 解析失败,跳过:${theme.url}`)
reports.push({ theme: theme.name, error: 'invalid url', hit: 0, miss: hasBase ? uniqSlices.length : 0 })
continue
}
const { fileKey: themeFileKey, nodeId: themeNodeId } = parsed
let themeDoc
try {
themeDoc = await fetchNodeTree(themeFileKey, themeNodeId, token)
} catch (e) {
console.log(` × fetchNodeTree 失败:${e.message}`)
reports.push({ theme: theme.name, error: e.message, hit: 0, miss: hasBase ? uniqSlices.length : 0 })
continue
}
const outDirRel = path.join(assetsDir, `theme-${theme.slug}`)
const outDirAbs = path.join(projectRoot, outDirRel)
ensureDir(outDirAbs)
// 决定这套稿子要切哪些位
let sliceItems, missNames = []
if (hasBase) {
// 建换肤稿匹配表:同 matchKey 下的多个节点按遍历顺序收成数组
// 基线里同结构 3 个 img-icon → 换肤稿也按顺序取对应 3 个,一对一
const themeGroups = new Map()
;(function walk(n, parentName) {
if (!n) return
const name = n.name || ''
if (name) {
const key = matchKey(name, parentName)
if (!themeGroups.has(key)) themeGroups.set(key, [])
themeGroups.get(key).push(n.id)
}
if (Array.isArray(n.children)) n.children.forEach(c => walk(c, name))
})(themeDoc, null)
// 基线里同 matchKey 出现第几次,就取换肤稿数组里第几个
const baseCursor = new Map()
sliceItems = []
for (const slice of uniqSlices) {
const cursor = baseCursor.get(slice._matchKey) || 0
baseCursor.set(slice._matchKey, cursor + 1)
const themeArr = themeGroups.get(slice._matchKey) || []
const themeNodeIdInSkin = themeArr[cursor]
if (!themeNodeIdInSkin) {
const detail = themeArr.length === 0
? '换肤稿无对应节点'
: `换肤稿仅 ${themeArr.length} 个同结构节点,基线第 ${cursor + 1} 个无匹配`
console.log(` ? miss ${slice.name}${slice.parentName ? ` (under ${slice.parentName})` : ''} (${detail})`)
missNames.push(slice.name)
continue
}
sliceItems.push({
name: slice.name,
parentName: slice.parentName,
filename: slice.filename,
nodeId: themeNodeIdInSkin,
})
}
} else {
// standalone:直接扫当前稿子;同结构 dedup 受 --dedupe-siblings 控制
const selfList = collectSliceNodes(themeDoc, args.prefixes)
let dedupedSelf = selfList
if (args.dedupeSiblings) {
const seenKeys = new Set()
dedupedSelf = []
for (const s of selfList) {
const key = matchKey(s.name, s.parentName)
if (seenKeys.has(key)) continue
seenKeys.add(key)
dedupedSelf.push(s)
}
}
// 冲突消解后再落名(跨父同名 → 父路径前缀区分)
const resolvedSelf = resolveFilenameCollisions(dedupedSelf)
sliceItems = resolvedSelf.map(s => ({
name: s.name,
parentName: s.parentName,
filename: s.filename,
nodeId: s.id,
renderBounds: s.renderBounds,
boundingBox: s.boundingBox,
}))
console.log(` 自扫切图清单:${sliceItems.length} 项(扫描到 ${selfList.length}, dedupe-siblings=${args.dedupeSiblings ? 'on' : 'off'})`)
}
// 逐位切图(串行,避免 Figma /v1/images 并发限流)
const hits = []
const manifestEntries = []
const sizeWarnings = []
for (const item of sliceItems) {
const destAbs = path.join(outDirAbs, `${item.filename}.png`)
try {
await exportImageToPath(themeFileKey, item.nodeId, token, destAbs)
const destRel = path.relative(projectRoot, destAbs)
const rb = item.renderBounds
// aligned-to-base 场景下 item.renderBounds 未透传(用的是换肤稿节点),不打 render 尺寸
const rTag = rb ? ` render=${Math.round(rb.width)}x${Math.round(rb.height)}` : ''
console.log(` · hit ${item.name} → ${destRel} (nodeId=${item.nodeId}${rTag})`)
hits.push({ name: item.name, path: destRel })
// v1.1.0 bg 溢出检测: png 尺寸应 ≈ node bbox × scale
// 主要针对 bg-* 前缀: 如果 Figma 把兄弟节点溢出烤进 png, png 尺寸会明显大于 bbox
const pseudoNode = item.boundingBox ? { absoluteBoundingBox: item.boundingBox } : null
const warn = pseudoNode ? assertPngSize(destAbs, pseudoNode, 2) : null
if (warn) {
console.log(` ⚠️ 尺寸告警: ${warn}`)
sizeWarnings.push({ nodeId: item.nodeId, name: item.name, reason: warn })
}
manifestEntries.push({
nodeId: item.nodeId,
name: item.name,
parentName: item.parentName || null,
filename: `${item.filename}.png`,
filepath: destRel,
renderWidth: rb ? Math.round(rb.width) : null,
renderHeight: rb ? Math.round(rb.height) : null,
bboxWidth: item.boundingBox ? Math.round(item.boundingBox.width) : null,
bboxHeight: item.boundingBox ? Math.round(item.boundingBox.height) : null,
sizeWarning: warn || null,
})
} catch (e) {
console.log(` × err ${item.name} (${e.message})`)
missNames.push(`${item.name} (${e.message})`)
}
}
reports.push({
theme: theme.name, slug: theme.slug, outDir: outDirRel,
hit: hits.length, miss: missNames.length, missNames,
mode: hasBase ? 'aligned-to-base' : 'standalone',
manifestEntries,
sizeWarnings,
})
console.log(` → ${theme.name}: hit=${hits.length}, ${hasBase ? 'miss' : 'err'}=${missNames.length}`)
}
// 汇总
console.log('\n[pp-d2c-reskin] ── 汇总 ──')
// 打时间戳给下游 agent 一个"本次跑的"标识,避免拿旧 PNG 当当前行为的证据
const stamp = new Date().toISOString().replace('T', ' ').slice(0, 19)
console.log(` 产物写入时间: ${stamp}`)
for (const r of reports) {
const tag = r.error ? `× ${r.error}` : `✓ hit=${r.hit} ${r.mode === 'standalone' ? 'err' : 'miss'}=${r.miss} [${r.mode}]`
console.log(` ${r.theme.padEnd(20)} ${tag} ${r.outDir || ''}`)
if (r.missNames?.length) {
console.log(` ${r.mode === 'standalone' ? 'errors' : 'missed'}: ${r.missNames.join(', ')}`)
}
}
// v1.1.0: 写清单供 pp-d2c Step 1.5 消费
if (args.outManifest) {
const manifest = {
generatedAt: stamp,
mode: hasBase ? 'aligned-to-base' : 'standalone',
themes: reports
.filter(r => !r.error)
.map(r => ({
slug: r.slug,
outDir: r.outDir,
hit: r.hit,
miss: r.miss,
entries: r.manifestEntries || [],
})),
}
const manifestAbs = path.isAbsolute(args.outManifest)
? args.outManifest
: path.resolve(projectRoot, args.outManifest)
ensureDir(path.dirname(manifestAbs))
fs.writeFileSync(manifestAbs, JSON.stringify(manifest, null, 2))
console.log(` 切图清单: ${path.relative(projectRoot, manifestAbs)}`)
}
}
main().catch(e => die(e.stack || e.message))
# pp-d2c-reskin Skill
> 用户给一批 figma 稿子,本 skill 按 `img` / `bg` 前缀规则扫图层树,把命中节点单独切图,归到 `<assetsDir>/theme-<slug>/` 子目录。**两种工作模式**:
>
> - **有基线**:先有一套跑过 pp-d2c 的页面(或用 `--base <url>` 显式指定基线稿),skill 按基线的切图清单去每套稿子上找**同名节点**切图,并报告 miss。用于"多套换肤稿对齐同一套代码"场景。
> - **无基线(standalone)**:什么参考都没有,skill 对每套稿子**独立扫**自己的图层树,前缀命中就切。用于"美术直接扔几套稿子,你只管按前缀规则切图"场景。
## 触发条件
- 用户说:「按图层前缀规则批量切图」「reskin 切图」「多套色版切图」「帮我把这几套稿子的 img/bg 切下来」
- 直接 `$pp-d2c-reskin --theme red=<figmaUrl> --theme gold=<figmaUrl> ...`
**不适用**:
- 需要生成代码结构 → 用主 `pp-d2c` / `pp-d2c-rn`
- 单张零散图片压缩 → 用 `pp-image-compress`
- 页面某一小块重生成 → 用 `pp-fix-partial`
## 前置条件
1. 项目根有 `pp-d2c.config.json`(跑过 `npx @double-coding/pixel-print init`,skill 从这里读 `images.assetsDir`)
2. `.env` 里配好 `FIGMA_TOKEN`(与主 pp-d2c 复用)
3. Node 18+(用内置 fetch)
4. **无强制要求**跑过基线;有则用,无则走 standalone
## 执行流程
### 步骤 0:识别当前模式
skill 启动时按以下优先级确定基线来源:
1. 用户传 `--base <figmaUrl>` → 用这个作为基线(**有基线**)
2. `.d2c-cache/last-page.json` 存在且完整 → 用主 SKILL 最近实现的整页作为基线(**有基线**)
3. 都没有 → **无基线**,走 standalone 模式
启动信息会明确打印当前模式(`base: <fileKey/nodeId (source)>` 或 `base: 无(standalone 模式,每套稿子独立切)`),避免用户以为在跟基线对齐但其实在 standalone。
### 步骤 1(可选):有基线时先跑 dry-run 看清单
**有基线时推荐**:先跑 dry-run 让用户确认清单再执行:
```bash
node .claude/skills/pp-d2c-reskin/reskin-slice.mjs --dry-run
# 或显式指定基线
node .claude/skills/pp-d2c-reskin/reskin-slice.mjs --base <baseFigmaUrl> --dry-run
```
**无基线时可跳过**:standalone 模式没有跨稿清单可预览,直接跑就行。真想预演可以先只传一套稿子看输出。
### 步骤 2:切图
正式跑,加上一套或多套 `--theme`:
```bash
# 有基线:每套稿子对齐基线切图清单
node .claude/skills/pp-d2c-reskin/reskin-slice.mjs \
--theme red=https://www.figma.com/design/DEF456/xxx?node-id=999-1 \
--theme gold=https://www.figma.com/design/DEF456/xxx?node-id=999-2
# 无基线:每套稿子独立扫自己的图层树
node .claude/skills/pp-d2c-reskin/reskin-slice.mjs \
--theme spring=https://www.figma.com/design/AAA/xxx?node-id=1-1 \
--theme autumn=https://www.figma.com/design/BBB/xxx?node-id=2-1
```
**有基线模式** 对每套稿子:
1. 内嵌的 Figma REST 客户端拉换肤稿子树
2. 按 **name 严格匹配**基线切图清单里的每一项
3. 命中 → `GET /v1/images` 拿 CDN URL → 下载到 `<assetsDir>/theme-<slug>/<原文件名>.png`
4. 未命中 → 记入 miss 报告(不阻断其它命中)
**无基线模式** 对每套稿子:
1. 内嵌的 Figma REST 客户端拉稿子子树
2. 直接遍历,`img` / `bg` / `img-*` / `bg-*` 前缀命中的节点就是切图位(每套稿子自扫独立清单)
3. 逐位导出 → `<assetsDir>/theme-<slug>/<name>.png`
4. 切图报错 → 记入 err 列表(不阻断其它)
### 步骤 3:产出汇总
脚本会输出每套稿子的模式标签(`[aligned-to-base]` / `[standalone]`)、hit/miss/err 数、miss 节点名列表、输出子目录路径。Agent 建议:
- **有基线** miss 多 → 让美术检查换肤稿图层命名是否与基线一致
- **无基线** hit 数远低于预期 → 让美术检查是否把该切的图层加了 `img` / `bg` 前缀
- 业务代码推荐写法:一个 `themeKey → assetsSubDir` 的映射就能切主题(有基线时文件名与基线对齐;无基线时不同稿子文件名可能不一致,以稿内实际图层名为准)
## 参数
| 参数 | 说明 |
|------|------|
| `--theme <name>=<figmaUrl>` | 一套稿子(可重复传多次);name 会 slug 化用作子目录名。至少传一套(除非 `--dry-run`) |
| `--dry-run` / `-n` | 只扫基线切图清单(有基线时),不拉稿子也不切图 |
| `--base <figmaUrl>` | 显式指定基线 URL(优先级高于 `last-page.json`) |
| `--prefix <list>` | 覆盖切图前缀(逗号分隔,不带 `-`;默认 `img,bg`) |
| `--dedupe-siblings` | 同父下同名节点只切第一个。默认关闭 —— 全都切,同名冲突用父路径前缀区分。**仅在 auto-layout 循环卡片刻意重复、切一次就够的场景才打开** |
## 切图清单如何认定
**基线切图位** = 基线子树中 name 匹配以下任一形式的所有节点(与 pp-d2c 主 SKILL §4 图层前缀体系对齐):
- `img` / `img-*` → 整层导出为 PNG(前景图片)。裸 `img` = 整块图片(整层就是图);`img-<name>` = 带语义的图片图层
- `bg` / `bg-*` → 背景图片(写父元素 `background-image`)。裸 `bg` = 整块背景;`bg-<name>` = 带语义的背景图层
**为什么支持裸标签**:美术在 figma 里给整块背景/整层图片命名时,可能直接叫 `bg` / `img`,不加子命名(尤其是内容语义已经很明显、不需要区分多张图的场景)。skill 兼容两种写法。
**裸 `img` / `bg` 的产物文件名**:因为没有子名,skill 会用**父节点 name** 作为文件名基础(如父节点 `sub-hero-card` 下的裸 `bg` → `hero-card__bg.png`),避免多个裸 `bg` 撞名。
**同 name 处理(默认全切,加 `--dedupe-siblings` 才去重)**:
- 跨父同名(3 个 `img-icon` 分处 Frame 722 / 726 / 730)→ **全部切出**,文件名自动加最近具体祖先前缀区分:`frame-722__icon.png` / `frame-726__icon.png` / `frame-730__icon.png`
- 同父同名(auto-layout 里循环卡片背景之类)→ 默认也全切,但如果确定只需一份,加 `--dedupe-siblings` 只切第一个
- 极少数二次撞名(父路径 slug 又相同)→ 再拼 nodeId 兜底,不丢图
**不再要求美术回改图层名**;skill 端自动消解冲突。
## 换肤节点匹配规则(仅有基线时执行)
**只有"有基线"模式才做跨稿匹配**。对每一个基线切图位,在换肤子树里找**同 key** 的对应节点:
- 匹配 key 恒为 **`<父节点 name>||<name>`**(裸标签、带子名统一走此规则)
- 同 matchKey 的多个节点(3 个 `img-icon` 各挂不同父)按遍历顺序**一一配对**:基线第 1 个对应换肤稿第 1 个,基线第 2 个对应换肤稿第 2 个
- 命中 → 切图落到 `<assetsDir>/theme-<slug>/<原文件名>.png`(文件名由 `resolveFilenameCollisions` 决定,同 basename 会加父路径前缀)
- 未命中 → 记入 miss 报告并**继续处理下一项**,不中断整套;miss 原因会区分「换肤稿无对应节点」还是「换肤稿仅 N 个同结构节点,基线第 M 个无匹配」
**为什么这样匹配**:换肤稿本质是"复制基线稿改颜色",图层树理应保持一致(含父节点命名与循环结构数量)。若换肤稿把**父 Frame 改名了**、或循环卡片数量与基线不一致,skill 会报 miss 让美术回改 —— 比容错匹配可能"切错节点"要好。
**无基线模式(standalone)** 不做跨稿匹配 —— 每套稿子按自身图层树独立扫,前缀命中就切,不与其它稿子对齐。
## 输出目录
```
<assetsDir>/
├── hero.png ← 基线(主 pp-d2c 产出)
├── card-top.png
├── cta-button.png
├── theme-red/ ← 本 skill 产出
│ ├── hero.png
│ ├── card-top.png
│ └── cta-button.png
└── theme-gold/
├── hero.png
├── card-top.png
└── cta-button.png
```
**有基线模式下文件名与基线严格对齐**,方便业务代码写一个 `themeKey → assetsSubDir` 的映射就能切主题:
```js
// 示例映射
const THEME_DIR = { default: '', red: 'theme-red/', gold: 'theme-gold/' }
const heroSrc = `./assets/${THEME_DIR[themeKey]}hero.png`
```
**无基线模式下文件名以每套稿子内实际图层名为准**,不同稿子间不保证文件名一致(靠美术自己保持图层命名规范)。
## 排查切图不符预期(给下游 agent 的硬规)
跑完 skill 后若发现某张 PNG "少东西 / 尺寸不对 / 内容不符预期",按以下顺序排查,**别自己临时写脚本发挥**:
1. **断言 PNG 内容前必须先重跑本 skill**
本地 `.png` 是**上一次跑的产物**,设计稿改动后不会自动同步。拿旧 PNG 当"当前行为"的证据是最常见的误诊来源。汇总行会打**产物写入时间**,与设计稿修改时间对比,晚于设计稿改动才是当前状态。
2. **看 `absoluteRenderBounds`,不是 `absoluteBoundingBox`**
Figma REST 返回**两个** bbox 字段:
- `absoluteBoundingBox` = 图层名义框(不含描边 / 投影 / 子元素溢出)
- `absoluteRenderBounds` = **实际渲染范围**(含以上所有),**Figma 出图按此裁剪**
本 skill 主 log 每一行 `render=W×H` 就是 renderBounds,直接读它,别 curl API 挑错字段。
3. **同名图层现在会全部切出**(带父路径前缀区分),**不要**建议美术回改图层名
3 个 `img-icon` 分处不同父 Frame → 会看到 `frame-722__icon.png` / `frame-726__icon.png` / `frame-730__icon.png`。若只想切一次(循环卡片背景之类),用 `--dedupe-siblings`。
4. **mask / clip 会锁 renderBounds**
GROUP 内有 mask RECTANGLE 时,Figma 的 renderBounds 会被锁在 mask 之内,子节点跑出 mask 范围的部分不会出图。这是 Figma 的规则,skill 端**无法绕过**(`/v1/images` 不接自定义 bbox)。修复只能改设计稿:
- 把 mask 拉大到包住溢出内容,或
- 把溢出的子节点(如浮动文字)移出 GROUP,由代码单独渲染
5. **想深入排查**:直接 `curl -H "X-Figma-Token: $FIGMA_TOKEN" "https://api.figma.com/v1/files/<key>/nodes?ids=<a>:<b>"` 拉节点树,先看 `absoluteRenderBounds`,再决定"是设计稿问题"还是"skill 问题"。
## 与其他 skill 的分工
| 场景 | 用哪个 |
|---|---|
| 首次整页 D2C | `pp-d2c` / `pp-d2c-rn` |
| 页面某一块单独重跑 | `pp-fix-partial` |
| 换肤稿子批量切图 | **`pp-d2c-reskin`(本 skill)** |
| 上线前剥 `data-node-id` | `pp-strip-nodeid` |
| 零散图片无损压缩 | `pp-image-compress` |
## 缓存与幂等
- **无缓存**:本 skill 是纯 REST 直连 Figma,每次都拉最新数据。这是刻意选择 —— 换肤场景通常是"美术改稿后重跑",缓存反而让你切到旧图
- 已存在的 `theme-<slug>/<name>.png` 会被**覆盖**(每次都从 figma 拉新的);想保护旧文件用 git 复核
- 想改成有缓存,自己在 `exportImageToPath` 加一层文件存在检查即可
## 禁止
- **有基线时**禁止跳过 dry-run 直接切图:一旦基线清单认错(比如 `last-page.json` 是老的 fileKey)会白切一堆无用图。**无基线时**没有跨稿清单可预览,直接跑即可
- 禁止把 `--theme` 的 name 写成含 `/` 或空格的字符串:会被 slug 化成 `-`,可读性差;推荐纯英文小写短名(red / gold / cny-2026)
- 禁止指望本 skill 生成代码:它只切图,不产任何 `.tsx` / `.jsx`;要代码走主 pp-d2c / pp-d2c-rn
- 禁止让本 skill 依赖兄弟 skill 的脚本(`pp-d2c/bin/figma.mjs` 等):本 skill 是**独立** REST 客户端,只依赖 `pp-d2c.config.json` 读 `assetsDir` + `.env` 读 `FIGMA_TOKEN`;兄弟 skill 不装也能跑
#!/usr/bin/env node
// check-rules.mjs — pp-d2c 硬防线脚本 (v1.2.1)
// 覆盖 R01/R02/R05/R06/R08/R16/R17/R18/R19/R20/R21
// v1.2.0 对账升级:loadCache 标注 _inBakedSubtree / _hidden / _templateDup;
// R02/R06 跳过 baked·隐藏·模板副本 + SCSS &__ 嵌套匹配(lib/cssMatch.mjs)消除假阳性;
// R17 禁 baked 子孙出 DOM(双重渲染);R18 flex-direction 忠实度;R19 padding 忠实度;R20 绝对定位坐标忠实度。
// v1.2.1:_inBakedSubtree 移除 bgc-(bgc- 非 baked,子孙走正常规则暴露误放);
// 新增 R21 node-id-coverage(应渲染节点必挂 data-node-id,机械强制 §5.1.1 铁律,堵 R18/R19/R20 空 classMap 逃逸)。
//
// 用法:
// node check-rules.mjs --block <blockDir> --cache-key <fileKey>
// node check-rules.mjs --merge <pageDir> --cache-key <fileKey>
// node check-rules.mjs --block <blockDir> --cache-key <fileKey> --force-skip R05,R06
//
// exit code:
// 0 — ok=true, 全通过 (可能有 warnings)
// 1 — ok=false, 有 violations
// 2 — 环境错误 (cache/产物/config 缺失)
import path from 'node:path';
import { findProjectRoot, loadConfig, loadCache } from './lib/loadCache.mjs';
import { loadProduct } from './lib/loadProduct.mjs';
import { buildNodeIdToClassName } from './lib/nodeIdToClassName.mjs';
import { makeReport, printReport } from './lib/report.mjs';
import * as R01 from './rules/R01-fixed-position.mjs';
import * as R02 from './rules/R02-fills-image.mjs';
import * as R05 from './rules/R05-space-between.mjs';
import * as R06 from './rules/R06-text-solid-last.mjs';
import * as R08 from './rules/R08-bg-landing-form.mjs';
import * as R16 from './rules/R16-no-flatten-text.mjs';
import * as R17 from './rules/R17-no-baked-dom.mjs';
import * as R18 from './rules/R18-flex-direction.mjs';
import * as R19 from './rules/R19-padding.mjs';
import * as R20 from './rules/R20-absolute-position.mjs';
import * as R21 from './rules/R21-node-id-coverage.mjs';
const ALL_RULES = [R01, R02, R05, R06, R08, R16, R17, R18, R19, R20, R21];
function parseArgv(argv) {
const args = { mode: null, dir: null, cacheKey: null, forceSkip: [] };
for (let i = 2; i < argv.length; i++) {
const a = argv[i];
if (a === '--block') { args.mode = 'block'; args.dir = argv[++i]; }
else if (a === '--merge') { args.mode = 'merge'; args.dir = argv[++i]; }
else if (a === '--cache-key') { args.cacheKey = argv[++i]; }
else if (a === '--force-skip') {
args.forceSkip = (argv[++i] || '').split(',').map((s) => s.trim()).filter(Boolean);
} else if (a === '-h' || a === '--help') {
printHelp();
process.exit(0);
}
}
return args;
}
function printHelp() {
process.stdout.write(`check-rules.mjs (pp-d2c v1.2.1)
Usage:
node check-rules.mjs --block <blockDir> --cache-key <fileKey>
node check-rules.mjs --merge <pageDir> --cache-key <fileKey>
node check-rules.mjs --block <blockDir> --cache-key <fileKey> --force-skip R05,R06
Rules covered: R01 R02 R05 R06 R08 R16 R17 R18 R19 R20 R21
Exit: 0=ok, 1=violations, 2=env-error
`);
}
function fatal(msg) {
process.stderr.write(`[check-rules] ERROR: ${msg}\n`);
process.exit(2);
}
function main() {
const args = parseArgv(process.argv);
if (!args.mode || !args.dir) fatal('missing --block <dir> or --merge <dir>');
if (!args.cacheKey) fatal('missing --cache-key <fileKey>');
const productDir = path.resolve(args.dir);
const product = loadProduct(productDir);
if (product.error) fatal(product.error);
if (product.jsx.length === 0 && product.style.length === 0) {
fatal(`no jsx/style found under ${productDir}`);
}
const projectRoot = findProjectRoot(productDir);
if (!projectRoot) fatal('pp-d2c.config.json not found in ancestors of ' + productDir);
const config = loadConfig(projectRoot);
if (!config) fatal('failed to load pp-d2c.config.json at ' + projectRoot);
const cache = loadCache(projectRoot, args.cacheKey);
if (cache.error) fatal(cache.error);
const classMap = buildNodeIdToClassName(product.jsx);
const checked = [];
const skipped = [];
const violations = [];
const warnings = [];
for (const rule of ALL_RULES) {
checked.push(rule.id);
if (args.forceSkip.includes(rule.id)) {
skipped.push(rule.id);
warnings.push({ rule: rule.id, reason: 'skipped via --force-skip' });
continue;
}
try {
const hits = rule.check({ cache, product, config, classMap });
for (const h of hits) violations.push(h);
} catch (e) {
warnings.push({ rule: rule.id, reason: `rule crashed: ${e.message}` });
}
}
const report = makeReport({ checked, skipped, violations, warnings });
printReport(report);
process.exit(report.ok ? 0 : 1);
}
main();
// cssMatch — 共享 CSS/SCSS 选择器匹配(v1.2.0)
//
// 背景:D2C 产物按 config.styleFormat 可能是平铺 CSS(`.page__foo { }`)
// 或 SCSS 嵌套(`.page { &__foo { } }`)。若规则只用 `\.classname` 平铺正则匹配,
// 遇到 SCSS `&__foo` / `&-foo` 嵌套写法会整体匹配不到 → 大量假阳性
// (test13 事故:R06 47 条假阳性全因产物是 `&__cd-num` 嵌套、正则找的是 `.page__cd-num`)。
//
// 本 lib 提炼自 R01 v1.1.0 补丁,统一供 R01/R02/R06/R18/R19 复用,杜绝各规则各自实现导致的嵌套盲区。
//
// 已知边界:正则按 `{...}` 最近闭合截取规则体,对"同块内既有声明又有嵌套子选择器"的场景
// 只截到第一个 `}`。D2C 产物的叶子选择器(承载具体声明)通常无内层嵌套,此边界可接受;
// 需要更强解析时再升级为词法栈解析。
const RE_ESC = /[.*+?^${}()|[\]\\]/g;
function esc(s) {
return s.replace(RE_ESC, '\\$&');
}
// className "test13-page__cd-num" → ["__cd-num", "-num"]
// className "page-fixed-bar" → ["-bar"](无 __)
// className "foo" → [](无分隔符,无 scss 嵌套形态可推)
export function deriveScssSuffixes(className) {
const out = new Set();
const idxDouble = className.lastIndexOf('__');
if (idxDouble >= 0) out.add(className.slice(idxDouble)); // "__cd-num"
const idxDash = className.lastIndexOf('-');
if (idxDash > 0) out.add('-' + className.slice(idxDash + 1)); // "-num"
return Array.from(out);
}
// 收集某 className 在一段 css 里的所有规则体(平铺 + SCSS &__ / &- 嵌套)。
// 返回 [{ body, line }](body = `{ }` 内文本,line = 选择器所在行)。
export function collectRuleBodies(css, className) {
const cn = (className || '').trim();
if (!cn) return [];
const out = [];
// 1) 平铺完整选择器: .foo { ... } | .parent .foo { ... }
const directRe = new RegExp(`\\.${esc(cn)}\\b[^{]*\\{([\\s\\S]*?)\\}`, 'g');
let m;
while ((m = directRe.exec(css)) !== null) {
out.push({ body: m[1], line: css.slice(0, m.index).split('\n').length });
}
// 2) SCSS 嵌套 &__xxx / &-xxx
for (const suffix of deriveScssSuffixes(cn)) {
const nestedRe = new RegExp(`&${esc(suffix)}\\b[^{]*\\{([\\s\\S]*?)\\}`, 'g');
while ((m = nestedRe.exec(css)) !== null) {
out.push({ body: m[1], line: css.slice(0, m.index).split('\n').length });
}
}
return out;
}
// 在多个 style 文件里,某 nodeId 的任一 className 是否有规则体命中 propRe。
// classes = classMap[nodeId](可能多个);styleFiles = product.style([{ content, rel }])。
// 命中返回 { hit:true, rel, line, body };否则 { hit:false, firstRel, firstLine, firstSnippet }
// (firstX 给出该 className 找到的第一个规则体,便于报错定位)。
export function findProperty(styleFiles, classes, propRe) {
let firstRel = null, firstLine = 0, firstSnippet = '';
for (const cls of classes || []) {
for (const s of styleFiles) {
const bodies = collectRuleBodies(s.content, cls);
for (const r of bodies) {
if (propRe.test(r.body)) {
return { hit: true, rel: s.rel, line: r.line, body: r.body };
}
if (!firstRel) {
firstRel = s.rel;
firstLine = r.line;
firstSnippet = r.body.slice(0, 200);
}
}
}
}
return { hit: false, firstRel, firstLine, firstSnippet };
}
// 取某 nodeId 第一个 className 的规则体(用于需要读取声明值的规则,如 R19 padding)。
// 返回 { body, rel, line } 或 null。
export function firstRuleBody(styleFiles, classes) {
for (const cls of classes || []) {
for (const s of styleFiles) {
const bodies = collectRuleBodies(s.content, cls);
if (bodies.length) return { body: bodies[0].body, rel: s.rel, line: bodies[0].line };
}
}
return null;
}
import fs from 'node:fs';
import path from 'node:path';
export function findProjectRoot(startDir) {
let dir = path.resolve(startDir);
while (true) {
if (fs.existsSync(path.join(dir, 'pp-d2c.config.json'))) return dir;
const parent = path.dirname(dir);
if (parent === dir) return null;
dir = parent;
}
}
export function loadConfig(projectRoot) {
const p = path.join(projectRoot, 'pp-d2c.config.json');
if (!fs.existsSync(p)) return null;
return JSON.parse(fs.readFileSync(p, 'utf8'));
}
// "子孙不生成独立 DOM" 的前缀:命中即整体导出 / 忽略,不再向内递归,其子孙不应作为
// 独立 DOM 出现,也不应被 R02/R06 逐个溯源。**仅两类**:
// - bg- / img-(含裸词 bg/img):整体切图,子孙像素**已烤进父层 PNG**(baked,可见)
// - x-:整体忽略,子孙**被丢弃**(ignored,不可见)
// **不含 bgc-**:bgc- 是"把盒级 CSS(fills/描边/圆角/阴影) 写到父元素",子孙**没有烤进任何位图**。
// 若把 bgc- 也算作 baked,会让误放在 bgc- 下的 TEXT 被静默跳过 R06 + R17 禁 DOM → 静默丢内容。
// 故 bgc- 子孙走正常规则:R06 会报"无 className"、R21 会报"不可追溯",把结构错误暴露出来而非吞掉。
const NO_RENDER_PREFIXES = ['bg-', 'img-', 'x-'];
const NO_RENDER_BARE = ['bg', 'img', 'x'];
export function isNonRecursivePrefix(name) {
if (!name || typeof name !== 'string') return false;
const n = name.trim();
if (NO_RENDER_PREFIXES.some((p) => n.startsWith(p))) return true;
if (NO_RENDER_BARE.includes(n)) return true; // 裸词 bg / img / x(与 R16 白名单口径一致)
return false;
}
// 结构签名:捕捉"同构"——同 type + 同层级子结构(深度 3,不含具体文案)。
// 用于识别 `.map()` 列表项:≥2 个同签名的容器兄弟 = 列表,非首项是数据副本。
function structureSig(node, depth) {
if (depth <= 0 || !Array.isArray(node.children) || node.children.length === 0) {
return node.type || '?';
}
return (node.type || '?') + '(' + node.children.map((c) => structureSig(c, depth - 1)).join(',') + ')';
}
// 标记某节点 children 中的"模板重复项":同构容器兄弟里的非首个 → __isDup=true。
// 仅对"有自身子结构的容器"生效(叶子如并列 TEXT "20"/"元" 不算列表项,不误标)。
function markTemplateDups(node) {
if (!Array.isArray(node.children) || node.children.length < 2) return;
const seen = new Map(); // sig -> 已出现
for (const c of node.children) {
if (!c || typeof c !== 'object' || !c.id) continue;
if (!Array.isArray(c.children) || c.children.length === 0) continue; // 叶子不参与列表判定
const sig = structureSig(c, 3);
if (seen.has(sig)) c.__isDup = true; // 非首个同构兄弟 = 数据副本
else seen.set(sig, true);
}
}
export function loadCache(projectRoot, cacheKey) {
const nodesDir = path.join(projectRoot, '.d2c-cache', cacheKey, 'nodes');
if (!fs.existsSync(nodesDir)) {
return { error: `cache dir not found: ${nodesDir}`, nodes: {} };
}
const nodes = {};
const files = fs.readdirSync(nodesDir).filter((f) => f.endsWith('.json'));
for (const f of files) {
const raw = fs.readFileSync(path.join(nodesDir, f), 'utf8');
let json;
try {
json = JSON.parse(raw);
} catch (e) {
continue;
}
// 自上而下遍历:跟踪 parent / "是否处于整体切图子树" / "是否隐藏" 状态,直接标注节点对象
// (nodes[id] 存的是同一对象引用,标注即对全局生效)。
walk(json, nodes, null, false, null, false, false);
}
return { nodes };
}
function walk(node, acc, parentRealId, inBaked, bakedBy, hidden, templateDup) {
if (!node || typeof node !== 'object') return;
let childParentId = parentRealId;
let childInBaked = inBaked;
let childBakedBy = bakedBy;
let childHidden = hidden;
let childTemplateDup = templateDup;
if (node.id && node.type) {
// 节点自身:继承祖先传下来的 baked 状态(前缀节点自身不算 baked,它是切图/忽略目标)
node._parentId = parentRealId;
node._inBakedSubtree = inBaked;
node._bakedBy = inBaked ? bakedBy : null;
// 隐藏传播:自身 visible===false 或任一祖先隐藏 → 该节点不渲染,对账应整体跳过
node._hidden = hidden || node.visible === false;
// 模板重复项:自身被父标为 __isDup(非首个同构兄弟),或祖先已是副本 → 整棵子树是数据副本
node._templateDup = templateDup || node.__isDup === true;
acc[node.id] = node;
childParentId = node.id;
childHidden = node._hidden;
childTemplateDup = node._templateDup;
// 自身若是非递归前缀,则其"子孙"进入 baked 子树(自身不进)
if (!inBaked && isNonRecursivePrefix(node.name)) {
childInBaked = true;
childBakedBy = node.id;
}
}
// 进入 children 前,标记本层的模板重复项(同构容器兄弟的非首个)
markTemplateDups(node);
if (Array.isArray(node.children)) {
for (const c of node.children) walk(c, acc, childParentId, childInBaked, childBakedBy, childHidden, childTemplateDup);
}
if (node.document) walk(node.document, acc, childParentId, childInBaked, childBakedBy, childHidden, childTemplateDup);
if (node.node) walk(node.node, acc, childParentId, childInBaked, childBakedBy, childHidden, childTemplateDup);
if (node.nodes && typeof node.nodes === 'object' && !Array.isArray(node.nodes)) {
for (const k of Object.keys(node.nodes)) walk(node.nodes[k], acc, childParentId, childInBaked, childBakedBy, childHidden, childTemplateDup);
}
}
import fs from 'node:fs';
import path from 'node:path';
const STYLE_EXTS = ['.scss', '.less', '.css', '.module.scss', '.module.less', '.module.css'];
const JSX_EXTS = ['.jsx', '.tsx'];
export function loadProduct(dir) {
const absDir = path.resolve(dir);
if (!fs.existsSync(absDir)) {
return { error: `product dir not found: ${absDir}`, jsx: [], style: [] };
}
const jsx = [];
const style = [];
walk(absDir, absDir, jsx, style);
return { root: absDir, jsx, style };
}
function walk(root, dir, jsx, style) {
const entries = fs.readdirSync(dir, { withFileTypes: true });
for (const e of entries) {
const full = path.join(dir, e.name);
if (e.isDirectory()) {
if (e.name === 'node_modules' || e.name.startsWith('.')) continue;
walk(root, full, jsx, style);
continue;
}
const ext = getExt(e.name);
const rel = path.relative(root, full);
if (JSX_EXTS.includes(ext)) {
jsx.push({ file: full, rel, content: fs.readFileSync(full, 'utf8') });
} else if (STYLE_EXTS.includes(ext)) {
style.push({ file: full, rel, content: fs.readFileSync(full, 'utf8') });
}
}
}
function getExt(name) {
if (name.endsWith('.module.scss')) return '.module.scss';
if (name.endsWith('.module.less')) return '.module.less';
if (name.endsWith('.module.css')) return '.module.css';
const i = name.lastIndexOf('.');
return i < 0 ? '' : name.slice(i);
}
// 从 jsx 里 grep 出 data-node-id={"X:Y"} 或 data-node-id="X:Y" 对应的 className={styles.foo} / className="foo"
// 建立 nodeId -> [className, ...] 的 map
export function buildNodeIdToClassName(jsxFiles) {
const map = new Map(); // nodeId -> Set<className>
for (const { content } of jsxFiles) {
scanFile(content, map);
}
const out = {};
for (const [k, v] of map) out[k] = Array.from(v);
return out;
}
function scanFile(src, map) {
// 简易 JSX 标签匹配 (足够 v1.0.0): 逐行 or 逐标签扫
// 用 <XXX ... data-node-id="..." ... /> 或 <XXX ... />...</XXX>
// 允许 attrs 之间任意换行 (className 和 data-node-id 顺序不定)
const tagRe = /<[A-Za-z][A-Za-z0-9-]*\b([^<>]*?)\/?>/gs;
let m;
while ((m = tagRe.exec(src)) !== null) {
const attrs = m[1];
if (!attrs) continue;
const nodeId = pickAttr(attrs, 'data-node-id');
if (!nodeId) continue;
const cls = pickClassName(attrs);
if (!cls || cls.length === 0) continue;
if (!map.has(nodeId)) map.set(nodeId, new Set());
for (const c of cls) map.get(nodeId).add(c);
}
}
function pickAttr(attrs, name) {
// name="value" 或 name={"value"} 或 name={'value'}
const re1 = new RegExp(`\\b${name}="([^"]+)"`);
const m1 = attrs.match(re1);
if (m1) return m1[1];
const re2 = new RegExp(`\\b${name}=\\{['"]([^'"]+)['"]\\}`);
const m2 = attrs.match(re2);
if (m2) return m2[1];
return null;
}
function pickClassName(attrs) {
// className="a b c"
const m1 = attrs.match(/\bclassName="([^"]+)"/);
if (m1) return m1[1].split(/\s+/).filter(Boolean);
// className={styles.foo}
const m2 = attrs.match(/\bclassName=\{styles\.([A-Za-z_][A-Za-z0-9_]*)\}/);
if (m2) return [m2[1]];
// className={`${styles.foo} ${styles.bar}`} — 简易匹配
const m3 = attrs.match(/\bclassName=\{`([^`]+)`\}/);
if (m3) {
const names = [];
const inner = m3[1];
const partRe = /styles\.([A-Za-z_][A-Za-z0-9_]*)/g;
let mm;
while ((mm = partRe.exec(inner)) !== null) names.push(mm[1]);
return names;
}
// className={clsx(styles.foo, styles.bar)} / className={classNames(...)}
const m4 = attrs.match(/\bclassName=\{(?:clsx|classNames|cx)\(([^)]+)\)\}/);
if (m4) {
const names = [];
const partRe = /styles\.([A-Za-z_][A-Za-z0-9_]*)/g;
let mm;
while ((mm = partRe.exec(m4[1])) !== null) names.push(mm[1]);
return names;
}
return null;
}
export function makeReport({ checked, skipped, violations, warnings }) {
const failed = Array.from(new Set(violations.map((v) => v.rule)));
const passed = checked.filter((r) => !failed.includes(r) && !skipped.includes(r));
return {
ok: violations.length === 0,
checked,
skipped,
passed,
failed,
violations,
warnings,
};
}
export function printReport(report) {
process.stdout.write(JSON.stringify(report, null, 2) + '\n');
}
// R01 fixed-position
// 触发: node.name.startsWith('fixed-')
// 期望: 产物 CSS 中该 node 对应类名规则内含 position: fixed
//
// v1.1.0 修复 (test12 事故):
// - 只走 classMap[nodeId] 反查, 禁止再走 name 派生兜底
// - SCSS 支持 &__foo / &-foo 嵌套语法, 通过 selector 后缀匹配
// - classMap 空 → 真报 R01 (jsx 缺 data-node-id 或未绑 className)
// v1.2.0: 嵌套匹配逻辑提炼到 lib/cssMatch.mjs 共享;跳过隐藏节点。
import { findProperty } from '../lib/cssMatch.mjs';
export const id = 'R01';
export const name = 'fixed-position';
export function check({ cache, product, classMap }) {
const violations = [];
const prefix = 'fixed-';
for (const [nodeId, node] of Object.entries(cache.nodes)) {
if (!node.name || !node.name.startsWith(prefix)) continue;
if (node._hidden) continue; // 隐藏节点不渲染,不校验
const classes = classMap[nodeId] || [];
if (classes.length === 0) {
violations.push({
rule: id,
nodeId,
name: node.name,
type: node.type,
expected: `jsx 里 data-node-id="${nodeId}" 元素绑定 className, 才能在 CSS 中反查 position: fixed`,
actual: '产物 jsx 未找到 data-node-id + className 映射; 若产物无该 nodeId 视为漏画',
file: '(missing in jsx)',
line: 0,
snippet: '',
});
continue;
}
const found = findProperty(product.style, classes, /position\s*:\s*fixed/i);
if (!found.hit) {
violations.push({
rule: id,
nodeId,
name: node.name,
type: node.type,
expected: 'css 含 position: fixed',
actual: 'css 未含 position: fixed (可能只有 relative / static / 无规则)',
file: found.firstRel || '(missing in style)',
line: found.firstLine || 0,
snippet: found.firstSnippet || '',
});
}
}
return violations;
}
// R02 fills-image
// 触发: node.fills[].some(f => f.type === 'IMAGE' && f.visible !== false)
// 期望:
// - assets.txt 中有该 nodeId 的切图记录 (fileName)
// - 产物 CSS (或 jsx <img>) 引用该切图
// 排斥: 节点前缀是 x- → 忽略
// v1.2.0: 跳过 baked 子树与隐藏节点;CSS url 匹配走 lib/cssMatch.mjs(修 &__ 嵌套盲区)。
import fs from 'node:fs';
import path from 'node:path';
import { collectRuleBodies } from '../lib/cssMatch.mjs';
export const id = 'R02';
export const name = 'fills-image';
export function check({ cache, product, config, classMap }) {
const violations = [];
const ignorePrefix = 'x-';
// 读 assets.txt (在 product root)
const assetsPath = path.join(product.root, 'assets.txt');
const assetsText = fs.existsSync(assetsPath) ? fs.readFileSync(assetsPath, 'utf8') : '';
for (const [nodeId, node] of Object.entries(cache.nodes)) {
if (!Array.isArray(node.fills) || node.fills.length === 0) continue;
const hasImage = node.fills.some((f) => f && f.type === 'IMAGE' && f.visible !== false);
if (!hasImage) continue;
if (node.name && node.name.startsWith(ignorePrefix)) continue;
// 处于 bg-/img- 整体切图子树(像素已烤进父层 PNG)或 x- 忽略子树内 → 不应逐个溯源。
// 跳过,消除对账假阳性(v1.2.0;v1.2.1 起不含 bgc-)。这类子孙的"禁 DOM"约束交由 R17。
if (node._inBakedSubtree) continue;
if (node._hidden) continue; // 隐藏节点不渲染,不校验
if (node._templateDup) continue; // .map() 列表数据副本,只校验代表项
// 检查 assets.txt 里是否提到此 nodeId
const inAssets = assetsText.includes(nodeId);
// 检查产物是否 (a) jsx 有 <img src=... nodeId 相关> 或 (b) style 有 url(...对应文件)
const productMention = mentionsNodeIdAsset(product, nodeId, classMap);
if (!inAssets && !productMention.hit) {
violations.push({
rule: id,
nodeId,
name: node.name || '(no name)',
type: node.type,
expected: 'assets.txt 有此 nodeId 切图记录 且 产物引用该切图',
actual: 'assets.txt 未记录 且 产物中未找到该 nodeId 相关 <img> / background url',
file: '(missing)',
line: 0,
snippet: '',
});
continue;
}
if (!productMention.hit) {
violations.push({
rule: id,
nodeId,
name: node.name || '(no name)',
type: node.type,
expected: '产物 jsx 或 CSS 引用该 nodeId 切图',
actual: 'assets.txt 已记录但产物未引用',
file: '(missing in product)',
line: 0,
snippet: '',
});
}
}
return violations;
}
function mentionsNodeIdAsset(product, nodeId, classMap) {
// 简易: nodeId 直接串在产物 (jsx / style) 里就算 hit;
// 或该 nodeId 对应 className 的 css 规则里含 url(
const idNorm = nodeId.replace(/:/g, '-');
for (const j of product.jsx) {
if (j.content.includes(nodeId) || j.content.includes(idNorm)) return { hit: true };
}
const classes = classMap[nodeId] || [];
for (const cls of classes) {
for (const s of product.style) {
for (const r of collectRuleBodies(s.content, cls)) {
if (/url\(/i.test(r.body) || /background-image\s*:/i.test(r.body)) return { hit: true };
}
}
}
for (const s of product.style) {
if (s.content.includes(nodeId) || s.content.includes(idNorm)) return { hit: true };
}
return { hit: false };
}
// R05 space-between
// 触发: primaryAxisAlignItems === 'SPACE_BETWEEN' (Figma AutoLayout)
// 期望: 对应 CSS 类含 justify-content: space-between
// 反向 warning: 类内含 margin-*:auto / justify-content:flex-* / gap:auto 之类模拟法
export const id = 'R05';
export const name = 'space-between';
export function check({ cache, product, config, classMap }) {
const violations = [];
for (const [nodeId, node] of Object.entries(cache.nodes)) {
if (node.primaryAxisAlignItems !== 'SPACE_BETWEEN') continue;
const classes = classMap[nodeId] || [];
if (classes.length === 0) {
violations.push({
rule: id,
nodeId,
name: node.name || '(no name)',
type: node.type,
expected: '产物 jsx 里 data-node-id 缺失或无 className',
actual: '产物中无对应 className',
file: '(missing in jsx)',
line: 0,
snippet: '',
});
continue;
}
let ok = false;
let hitFile = null;
let hitLine = 0;
let hitSnippet = '';
for (const cls of classes) {
for (const s of product.style) {
const rules = collectRules(s.content, cls);
for (const r of rules) {
if (/justify-content\s*:\s*space-between/i.test(r.body)) {
ok = true;
break;
}
if (!hitFile) {
hitFile = s.rel;
hitLine = r.line;
hitSnippet = r.snippet;
}
}
if (ok) break;
}
if (ok) break;
}
if (!ok) {
violations.push({
rule: id,
nodeId,
name: node.name || '(no name)',
type: node.type,
expected: 'css 含 justify-content: space-between',
actual: 'css 未含 justify-content: space-between (可能用 margin/flex-end 模拟)',
file: hitFile || '(missing in style)',
line: hitLine,
snippet: hitSnippet,
});
}
}
return violations;
}
function collectRules(css, className) {
const re = new RegExp(`\\.${escapeRegex(className)}\\b[^{]*\\{([\\s\\S]*?)\\}`, 'g');
const out = [];
let m;
while ((m = re.exec(css)) !== null) {
const line = css.slice(0, m.index).split('\n').length;
out.push({ line, body: m[1], snippet: m[0].slice(0, 200) });
}
return out;
}
function escapeRegex(s) {
return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
}
// R06 text-solid-last
// 触发: TEXT 节点,fills 数组非空,末位可见 fill 是 SOLID
// 期望: 对应 CSS 类含 color: #HEX (与 SOLID.color 匹配)
// 排斥: 末位可见 fill 是 GRADIENT/IMAGE → 归 R04 判定,不在此处
// v1.2.0: 跳过 baked 子树 TEXT 与隐藏节点;SCSS 嵌套匹配走 lib/cssMatch.mjs(修 &__ 盲区假阳性)。
import { collectRuleBodies } from '../lib/cssMatch.mjs';
export const id = 'R06';
export const name = 'text-solid-last';
export function check({ cache, product, config, classMap }) {
const violations = [];
for (const [nodeId, node] of Object.entries(cache.nodes)) {
if (node.type !== 'TEXT') continue;
if (!Array.isArray(node.fills) || node.fills.length === 0) continue;
// 处于 bg-/img- 整体切图子树(文字像素已烤进父层 PNG)或 x- 忽略子树内的 TEXT →
// 不作为独立 DOM 渲染,无需校验字色。跳过(v1.2.0;v1.2.1 起不含 bgc-)。禁 DOM 交由 R17。
if (node._inBakedSubtree) continue;
if (node._hidden) continue; // 隐藏 TEXT 不渲染,不校验
if (node._templateDup) continue; // .map() 列表数据副本,只校验代表项
const lastVisible = pickLastVisibleFill(node.fills);
if (!lastVisible) continue; // 全 invisible → 走默认 (略过)
if (lastVisible.type !== 'SOLID') continue; // 走 R04
const expectedHex = rgbaToHex(lastVisible.color);
if (!expectedHex) continue;
const classes = classMap[nodeId] || [];
// 不可追溯(无 className/data-node-id)→ 交由 R21 node-id-coverage 统一报"应挂 id",
// R06 只负责"可追溯 TEXT 的字色是否取对",避免与 R21 双报同一节点(v1.2.1)。
if (classes.length === 0) continue;
let ok = false;
let hitFile = null;
let hitLine = 0;
let hitSnippet = '';
let actualColor = null;
for (const cls of classes) {
for (const s of product.style) {
const rules = collectRuleBodies(s.content, cls);
for (const r of rules) {
const colorMatch = r.body.match(/(?:^|[^-\w])color\s*:\s*(#[0-9a-fA-F]{3,8})/);
if (colorMatch) {
const found = normalizeHex(colorMatch[1]);
if (found === expectedHex) { ok = true; break; }
if (!actualColor) actualColor = found;
}
if (!hitFile) { hitFile = s.rel; hitLine = r.line; hitSnippet = r.body.slice(0, 200); }
}
if (ok) break;
}
if (ok) break;
}
if (!ok) {
violations.push({
rule: id,
nodeId,
name: node.name || '(no name)',
type: node.type,
expected: `css 含 color: ${expectedHex} (源自 fills 末位可见 SOLID)`,
actual: actualColor ? `css color: ${actualColor} (与 SOLID 不符)` : 'css 未含 color',
file: hitFile || '(missing in style)',
line: hitLine,
snippet: hitSnippet,
});
}
}
return violations;
}
function pickLastVisibleFill(fills) {
for (let i = fills.length - 1; i >= 0; i--) {
const f = fills[i];
if (f && f.visible !== false) return f;
}
return null;
}
function rgbaToHex(color) {
if (!color) return null;
const r = Math.round((color.r || 0) * 255);
const g = Math.round((color.g || 0) * 255);
const b = Math.round((color.b || 0) * 255);
return ('#' + [r, g, b].map((n) => n.toString(16).padStart(2, '0')).join('')).toLowerCase();
}
function normalizeHex(hex) {
let h = hex.toLowerCase();
if (h.length === 4) {
h = '#' + h[1] + h[1] + h[2] + h[2] + h[3] + h[3];
} else if (h.length === 9) {
h = h.slice(0, 7); // 忽略 alpha
}
return h;
}
// R08 bg-landing-form
// 触发: node.name.startsWith('bg-') 或 name === 'bg'
// 反向扫产物,禁止:
// - jsx: <img ... src=".../bg-..." ... /> 或 <img ... src=".../bg.<ext>" ... />
// - jsx: style={{ ... background... }}
// - jsx: className="...-bg..." 里空 div (无 children) 且挂 bg
// - scss: ::before / ::after { ... background-image ... }
export const id = 'R08';
export const name = 'bg-landing-form';
export function check({ cache, product, config, classMap }) {
const violations = [];
const bgPrefix = 'bg-';
// 先看 cache 有没有 bg- 节点 — 用于生成 nodeId 提示
const bgNodes = [];
for (const [nodeId, node] of Object.entries(cache.nodes)) {
if (!node.name) continue;
if (node.name.startsWith(bgPrefix) || node.name === 'bg') {
bgNodes.push({ nodeId, name: node.name });
}
}
if (bgNodes.length === 0) return violations;
// jsx 反向扫
for (const j of product.jsx) {
const lines = j.content.split('\n');
for (let i = 0; i < lines.length; i++) {
const line = lines[i];
// <img src=".../bg-XXX" or .../bg.<ext>
if (/<img\b[^>]*src=[^>]*bg-/i.test(line)) {
violations.push(mkV(id, j.rel, i + 1, line.trim(), '<img src="...bg-..."> 应用父容器 background-image 代替', bgNodes[0]));
} else if (/<img\b[^>]*src=[^>]*\/bg\.[a-z]{2,5}["'`)]/i.test(line)) {
violations.push(mkV(id, j.rel, i + 1, line.trim(), '<img src="...bg.<ext>"> 应用父容器 background-image 代替', bgNodes[0]));
}
// inline style background
if (/style=\{\{[^}]*background/i.test(line)) {
violations.push(mkV(id, j.rel, i + 1, line.trim(), 'inline style 挂 background 应用 className + scss background', bgNodes[0]));
}
}
// 空 div 挂 bg-* (整个 <div /> 或空标签,自闭合 or 只有空白 children)
// 简易匹配: <div className={styles.bg...} data-node-id=... />
const emptyBgDivRe = /<div\b[^>]*className=\{styles\.(bg[A-Za-z0-9_]*)\}[^>]*\/>/g;
let m;
while ((m = emptyBgDivRe.exec(j.content)) !== null) {
const idx = j.content.slice(0, m.index).split('\n').length;
violations.push(mkV(id, j.rel, idx, m[0], '空 <div className={styles.bg...} /> 应作为父容器 background 或用父节点直接挂', bgNodes[0]));
}
}
// scss 反向扫 ::before / ::after 挂 background-image
for (const s of product.style) {
// 简易: 检索 ::before { ... background-image ... } 块
const pseudoRe = /::(before|after)\s*\{([\s\S]*?)\}/g;
let m;
while ((m = pseudoRe.exec(s.content)) !== null) {
if (/background-image\s*:/i.test(m[2]) || /background\s*:[^;]*url\(/i.test(m[2])) {
const idx = s.content.slice(0, m.index).split('\n').length;
violations.push(mkV(id, s.rel, idx, m[0].slice(0, 200), `::${m[1]} 挂 background-image 应改为父节点直接 background`, bgNodes[0]));
}
}
}
return violations;
}
function mkV(rule, file, line, snippet, expected, bgNode) {
return {
rule,
nodeId: bgNode ? bgNode.nodeId : '(bg node in cache)',
name: bgNode ? bgNode.name : '(bg node)',
type: 'JSX/SCSS',
expected,
actual: snippet,
file,
line,
snippet,
};
}
// R16 no-flatten-text
// 触发: GROUP/FRAME/COMPONENT/INSTANCE 子树含 TEXT,且节点 name 前缀不在白名单
// 白名单: img- / bg- (含裸词 img / bg,全等或以 xxx- 开头)
// 反查: 产物 jsx 中出现 `<img ... data-node-id="<该节点>" ... />` → 违规
//
// 语义: 禁止用整体切图替代含 TEXT 的容器;否则 TEXT 无障碍缺失、无法本地化、按钮不可点击
// 排斥: img-/bg- 节点自身就是拿来切图/挂 background 的,天然免疫
export const id = 'R16';
export const name = 'no-flatten-text';
const WHITELIST_PREFIXES = ['img-', 'bg-'];
const WHITELIST_BARE = ['img', 'bg'];
const CONTAINER_TYPES = new Set(['GROUP', 'FRAME', 'COMPONENT', 'INSTANCE']);
export function check({ cache, product }) {
const violations = [];
for (const [nodeId, node] of Object.entries(cache.nodes)) {
if (!node.type || !CONTAINER_TYPES.has(node.type)) continue;
if (!node.name) continue;
if (isWhitelisted(node.name)) continue;
if (!subtreeHasText(node, cache.nodes)) continue;
const hits = findImgReferencingNode(product.jsx, nodeId);
for (const hit of hits) {
violations.push({
rule: id,
nodeId,
name: node.name,
type: node.type,
expected: `不得对含 TEXT 的 ${node.type}(前缀非 img-/bg-)整体切图;应按 §4.3 前缀规则拆解 TEXT / btn / img / bg 子节点`,
actual: `产物 jsx 出现 <img data-node-id="${nodeId}">,意味着该容器被整体烤成位图`,
file: hit.file,
line: hit.line,
snippet: hit.snippet,
});
}
}
return violations;
}
function isWhitelisted(nodeName) {
const name = nodeName.trim();
if (WHITELIST_BARE.includes(name)) return true;
for (const p of WHITELIST_PREFIXES) {
if (name.startsWith(p) && name.length > p.length) return true;
}
return false;
}
// 递归判定:子树内是否存在 TEXT 节点
// 通过 cache.nodes 反查 children(避免 node 对象子引用不全)
function subtreeHasText(root, allNodes) {
const stack = [root];
const visited = new Set();
while (stack.length) {
const cur = stack.pop();
if (!cur || !cur.id || visited.has(cur.id)) continue;
visited.add(cur.id);
if (cur.type === 'TEXT') return true;
if (Array.isArray(cur.children)) {
for (const child of cur.children) {
if (child && child.id && allNodes[child.id]) {
stack.push(allNodes[child.id]);
} else {
stack.push(child);
}
}
}
}
return false;
}
// 在 jsx 里搜 <img ... data-node-id="<nodeId>" ... />
// 允许属性顺序任意;跨行也扫(松散匹配到 </img> 或 />)
function findImgReferencingNode(jsxFiles, nodeId) {
const hits = [];
const escaped = nodeId.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
// 匹配 <img ...(可跨行)... data-node-id="<nodeId>" ...(可跨行)... />
// 或反向:data-node-id 在前、闭合在后
const re = new RegExp(`<img\\b[^>]*?data-node-id=["']${escaped}["'][^>]*?/?>`, 'gs');
for (const j of jsxFiles) {
let m;
while ((m = re.exec(j.content)) !== null) {
const before = j.content.slice(0, m.index);
const line = before.split('\n').length;
hits.push({
file: j.rel,
line,
snippet: m[0].length > 200 ? m[0].slice(0, 200) + '...' : m[0],
});
}
}
return hits;
}
// R17 no-baked-dom(v1.2.0 对账新增)
// 触发: 节点处于 bg-/bgc-/img-/x- 整体切图子树内(_inBakedSubtree=true)
// 期望: 该节点的像素已烤进父层切图(或被 x- 整体忽略),产物中【不得】再有其 data-node-id 元素
// 违反: 产物 JSX 出现 data-node-id="<nodeId>" → 双重渲染(文字/图叠一遍,典型 test13 title-text/subtitle 既进 main.png 又出 DOM)
//
// 与 R02/R06 的分工: R02/R06 跳过 baked 子孙(不逐个溯源),"禁 DOM" 由本条正向兜底。
export const id = 'R17';
export const name = 'no-baked-dom';
export function check({ cache, product }) {
const violations = [];
for (const [nodeId, node] of Object.entries(cache.nodes)) {
if (!node._inBakedSubtree) continue;
if (node._templateDup) continue; // 数据副本,代表项报过即可,避免重复
// x- 忽略子树里本就不该出现,bg-/bgc-/img- 烤进切图更不该;隐藏与否都不应出 DOM
const hit = findDomNode(product, nodeId);
if (hit) {
const bakedByNode = node._bakedBy ? cache.nodes[node._bakedBy] : null;
const bakedByName = (bakedByNode && bakedByNode.name) || node._bakedBy || '?';
const isIgnored = /^x[-]?/.test(String(bakedByName).trim()) || String(bakedByName).trim() === 'x';
const kind = isIgnored
? `处于 x- 忽略子树内(bakedBy=${bakedByName}),该内容被整体忽略,不该渲染`
: `处于整体切图子树内(bakedBy=${bakedByName}),像素已烤进父层 PNG`;
violations.push({
rule: id,
nodeId,
name: node.name || '(no name)',
type: node.type,
expected: `节点${kind},产物不应有其 data-node-id 元素`,
actual: `产物 ${hit.rel} 出现 data-node-id="${nodeId}"(${isIgnored ? '被忽略内容却出 DOM' : '双重渲染:切图一份 + DOM 一份'})`,
file: hit.rel,
line: hit.line,
snippet: hit.snippet,
});
}
}
return violations;
}
function findDomNode(product, nodeId) {
const idNorm = nodeId.replace(/:/g, '-');
const re = new RegExp(`data-node-id=(?:"|\\{['"])(?:${escapeRegex(nodeId)}|${escapeRegex(idNorm)})(?:"|['"]\\})`);
for (const j of product.jsx) {
const m = j.content.match(re);
if (m) {
const line = j.content.slice(0, m.index).split('\n').length;
const lineText = j.content.split('\n')[line - 1] || '';
return { rel: j.rel, line, snippet: lineText.trim().slice(0, 200) };
}
}
return null;
}
function escapeRegex(s) {
return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
}
// R18 flex-direction(v1.2.0 对账新增)
// 触发: autolayout 容器(layoutMode === 'HORIZONTAL' | 'VERTICAL')
// 期望: VERTICAL → CSS 含 flex-direction: column;HORIZONTAL → 不得写 flex-direction: column(row 为 flex 默认,可省)
// 违反: 方向写反(典型 test13 small-card-top:Figma VERTICAL 却写 flex-direction: row)
// 跳过: baked / hidden / templateData 副本 / 无 className(不可追溯,交由 §5.1 data-node-id 铁律在生成侧兜底)
import { collectRuleBodies } from '../lib/cssMatch.mjs';
export const id = 'R18';
export const name = 'flex-direction';
export function check({ cache, product, classMap }) {
const violations = [];
for (const [nodeId, node] of Object.entries(cache.nodes)) {
const lm = node.layoutMode;
if (lm !== 'HORIZONTAL' && lm !== 'VERTICAL') continue;
if (node._inBakedSubtree || node._hidden || node._templateDup) continue;
const classes = classMap[nodeId] || [];
if (classes.length === 0) continue; // 不可追溯:本条不报,由 §5.1 生成铁律保证挂 id
const body = firstBodyWithFlex(product.style, classes);
if (!body) continue; // 无 display:flex 规则体(可能非 flex 实现),不在本条判定
const dir = extractFlexDirection(body);
if (lm === 'VERTICAL') {
if (dir !== 'column') {
violations.push(mk(nodeId, node, 'flex-direction: column(Figma layoutMode=VERTICAL)', dir ? `flex-direction: ${dir}` : '未写 flex-direction(默认 row,纵向布局会横排)'));
}
} else {
// HORIZONTAL
if (dir === 'column') {
violations.push(mk(nodeId, node, 'flex-direction: row 或省略(Figma layoutMode=HORIZONTAL)', 'flex-direction: column(方向写反)'));
}
}
}
return violations;
function mk(nodeId, node, expected, actual) {
return { rule: id, nodeId, name: node.name || '(no name)', type: node.type, expected, actual, file: '(style)', line: 0, snippet: '' };
}
}
// 找该 nodeId 第一个含 display:flex 的规则体
function firstBodyWithFlex(styleFiles, classes) {
for (const cls of classes) {
for (const s of styleFiles) {
for (const r of collectRuleBodies(s.content, cls)) {
if (/display\s*:\s*flex/i.test(r.body)) return r.body;
}
}
}
return null;
}
function extractFlexDirection(body) {
const m = body.match(/flex-direction\s*:\s*([a-z-]+)/i);
return m ? m[1].toLowerCase() : null;
}
// R19 padding(v1.2.0 对账新增)
// 触发: autolayout 容器且 Figma 声明了 padding(paddingTop/Right/Bottom/Left 任一非 0),或产物写了 padding
// 期望: CSS padding 四值 ≈ Figma paddingT/R/B/L × scale(容差 2px)
// 违反:
// - Figma pad0 但产物写了非 0 padding(凭空捏造,典型 test13 small-card-top: Figma 四边 0 却写 padding:0 12px)
// - Figma 有 padding 但产物缺失或数值对不上
// 跳过: baked / hidden / templateDup 副本 / 无 className
import { collectRuleBodies } from '../lib/cssMatch.mjs';
export const id = 'R19';
export const name = 'padding';
export function check({ cache, product, config, classMap }) {
const violations = [];
const scale = (config && config.unit && config.unit.scale) || 2;
const TOL = 2;
for (const [nodeId, node] of Object.entries(cache.nodes)) {
const lm = node.layoutMode;
if (lm !== 'HORIZONTAL' && lm !== 'VERTICAL') continue; // padding 仅在 autolayout 容器有意义
if (node._inBakedSubtree || node._hidden || node._templateDup) continue;
const classes = classMap[nodeId] || [];
if (classes.length === 0) continue;
const body = firstBody(product.style, classes);
if (!body) continue;
// Figma 期望值(未声明视为 0)
const fig = [
Math.round((node.paddingTop || 0) * scale),
Math.round((node.paddingRight || 0) * scale),
Math.round((node.paddingBottom || 0) * scale),
Math.round((node.paddingLeft || 0) * scale),
];
const figAllZero = fig.every((v) => v === 0);
const css = extractPadding(body); // null | [t,r,b,l]
if (!css) {
// 产物未写 padding:Figma 也 0 → OK;Figma 有 padding → 缺失违规
if (!figAllZero) {
violations.push(mk(nodeId, node, `padding: ${fig.join('px ')}px(Figma ×${scale})`, '产物未写 padding'));
}
continue;
}
// 逐边比对
const bad = css.some((v, i) => Math.abs(v - fig[i]) > TOL);
if (bad) {
const reason = figAllZero ? '(Figma 四边 padding 均为 0,产物凭空加了 padding)' : '';
violations.push(mk(nodeId, node, `padding ≈ [${fig.join(', ')}]px(Figma ×${scale})`, `产物 padding = [${css.join(', ')}]px${reason}`));
}
}
return violations;
function mk(nodeId, node, expected, actual) {
return { rule: id, nodeId, name: node.name || '(no name)', type: node.type, expected, actual, file: '(style)', line: 0, snippet: '' };
}
}
function firstBody(styleFiles, classes) {
for (const cls of classes) {
for (const s of styleFiles) {
const b = collectRuleBodies(s.content, cls);
if (b.length) return b[0].body;
}
}
return null;
}
// 解析 CSS padding 简写 / 拆分为 [top,right,bottom,left](px)。取规则体里"最后一次" padding 声明。
// 只认 px 值;含非 px(%/auto/var)→ 放弃比对返回 null(不误报)。
function extractPadding(body) {
// longhand 优先覆盖 shorthand:先取 shorthand,再用 longhand 覆盖对应边
let vals = null;
const sh = lastMatch(body, /(?:^|[^-\w])padding\s*:\s*([^;}]+)/gi);
if (sh) {
const parts = sh.trim().split(/\s+/);
const nums = [];
for (const p of parts) {
const mm = p.match(/^(-?\d+(?:\.\d+)?)(px)?$/);
if (!mm) return null; // 含 %/auto/var/calc 等非 px → 放弃比对,不误报
if (!mm[2] && parseFloat(mm[1]) !== 0) return null; // 无单位非 0(如 rem 缺写)→ 放弃
nums.push(parseFloat(mm[1])); // 无单位 0 或带 px → 取数值
}
if (nums.length === 1) vals = [nums[0], nums[0], nums[0], nums[0]];
else if (nums.length === 2) vals = [nums[0], nums[1], nums[0], nums[1]];
else if (nums.length === 3) vals = [nums[0], nums[1], nums[2], nums[1]];
else if (nums.length >= 4) vals = [nums[0], nums[1], nums[2], nums[3]];
}
const lh = {
0: lastMatch(body, /padding-top\s*:\s*(-?\d+(?:\.\d+)?)px/gi),
1: lastMatch(body, /padding-right\s*:\s*(-?\d+(?:\.\d+)?)px/gi),
2: lastMatch(body, /padding-bottom\s*:\s*(-?\d+(?:\.\d+)?)px/gi),
3: lastMatch(body, /padding-left\s*:\s*(-?\d+(?:\.\d+)?)px/gi),
};
const hasLh = Object.values(lh).some((x) => x != null);
if (!vals && !hasLh) return null;
if (!vals) vals = [0, 0, 0, 0];
for (const i of [0, 1, 2, 3]) if (lh[i] != null) vals[i] = parseFloat(lh[i]);
return vals.map((v) => Math.round(v));
}
function lastMatch(body, re) {
let m, last = null;
while ((m = re.exec(body)) !== null) last = m[1];
return last;
}
// R20 absolute-position(v1.2.0 对账新增)
// 触发: node.layoutPositioning === 'ABSOLUTE'(脱离父 autolayout 顺流,绝对定位)
// 期望: CSS top ≈ (子.bbox.y − 父.bbox.y) × scale;left ≈ (子.bbox.x − 父.bbox.x) × scale(容差 4px)
// 违反: 坐标靠猜(典型 test13 img-huochepiao:真值 left≈-10/top≈-13 溢出到背景上,产物却写 top:40/left:40)
// 跳过: baked / hidden / templateDup / 无 className / 父无 bbox
//
// 核心哲学: 能从 bbox 精确算出的坐标,禁止靠猜 + "需人工核对" 兜底(§6.0.2 已封该逃逸口)。
import { collectRuleBodies } from '../lib/cssMatch.mjs';
export const id = 'R20';
export const name = 'absolute-position';
export function check({ cache, product, config, classMap }) {
const violations = [];
const scale = (config && config.unit && config.unit.scale) || 2;
const TOL = 4;
for (const [nodeId, node] of Object.entries(cache.nodes)) {
if (node.layoutPositioning !== 'ABSOLUTE') continue;
if (node._inBakedSubtree || node._hidden || node._templateDup) continue;
// fixed- 前缀走 constraints 视口定位(R01 域),不是 (子bbox−父bbox) 相对定位,跳过
if (node.name && node.name.startsWith('fixed-')) continue;
const parent = node._parentId ? cache.nodes[node._parentId] : null;
const nb = node.absoluteBoundingBox;
const pb = parent && parent.absoluteBoundingBox;
if (!nb || !pb) continue; // 缺 bbox 无法精确计算,不误报
const classes = classMap[nodeId] || [];
if (classes.length === 0) continue; // 不可追溯,交由 §5.1 生成铁律
const body = firstBody(product.style, classes);
if (!body) continue;
const expLeft = Math.round((nb.x - pb.x) * scale);
const expTop = Math.round((nb.y - pb.y) * scale);
// inset 简写兜底:inset: <top> <right> <bottom> <left> | inset: 0(四边)
const inset = extractInset(body);
const cssTop = extractPos(body, 'top', inset ? inset[0] : null);
const cssLeft = extractPos(body, 'left', inset ? inset[3] : null);
// 期望值≈0 且产物未显式声明 → 原点绝对定位与顺流视觉等价,容忍不报(避免噪声)。
// 期望非 0 却缺失(丢了真实偏移)、或写了值但对不上(如 huochepiao 40 vs -13)→ 报。
const problems = [];
if (cssTop == null) {
if (Math.abs(expTop) > TOL) problems.push(`缺 top(应 ${expTop}px,丢了真实偏移)`);
} else if (Math.abs(cssTop - expTop) > TOL) problems.push(`top=${cssTop}px 应 ${expTop}px`);
if (cssLeft == null) {
if (Math.abs(expLeft) > TOL) problems.push(`缺 left(应 ${expLeft}px,丢了真实偏移)`);
} else if (Math.abs(cssLeft - expLeft) > TOL) problems.push(`left=${cssLeft}px 应 ${expLeft}px`);
if (problems.length) {
violations.push({
rule: id,
nodeId,
name: node.name || '(no name)',
type: node.type,
expected: `top≈${expTop}px left≈${expLeft}px((子bbox−父bbox)×${scale},父=${node._parentId})`,
actual: problems.join(';'),
file: '(style)',
line: 0,
snippet: '',
});
}
}
return violations;
}
function firstBody(styleFiles, classes) {
for (const cls of classes) {
for (const s of styleFiles) {
const b = collectRuleBodies(s.content, cls);
if (b.length) return b[0].body;
}
}
return null;
}
// 取 top/left 值:兼容带 px 与无单位 0(`top: 0`);无显式声明时回落 inset 值。
function extractPos(body, prop, insetVal) {
const re = new RegExp(`(?:^|[^-\\w])${prop}\\s*:\\s*(-?\\d+(?:\\.\\d+)?)(px)?\\b`, 'i');
const m = body.match(re);
if (m) {
const num = parseFloat(m[1]);
if (m[2] || num === 0) return num; // 有 px,或无单位的 0
return null; // 无单位的非 0(%/未知)→ 不比对
}
return insetVal; // 回落 inset
}
// 解析 inset 简写为 [top,right,bottom,left](px 或无单位 0);含非纯数值 → null
function extractInset(body) {
const m = body.match(/(?:^|[^-\w])inset\s*:\s*([^;}]+)/i);
if (!m) return null;
const parts = m[1].trim().split(/\s+/);
const nums = [];
for (const p of parts) {
const mm = p.match(/^(-?\d+(?:\.\d+)?)(px)?$/);
if (!mm) return null;
if (!mm[2] && parseFloat(mm[1]) !== 0) return null; // 无单位非 0 放弃
nums.push(parseFloat(mm[1]));
}
if (nums.length === 1) return [nums[0], nums[0], nums[0], nums[0]];
if (nums.length === 2) return [nums[0], nums[1], nums[0], nums[1]];
if (nums.length === 3) return [nums[0], nums[1], nums[2], nums[1]];
if (nums.length >= 4) return [nums[0], nums[1], nums[2], nums[3]];
return null;
}
// R21 node-id-coverage(v1.2.1 对账新增)
// 触发: "应生成独立 DOM"的节点,产物 JSX 里找不到其 data-node-id
// 目的: 让 §5.1.1「data-node-id 全覆盖铁律」机械强制。没有 node-id,R06/R18/R19/R20 全都
// 绑定不到产物 → 遇空 classMap 只能 continue,bug 静默逃逸(典型 test13 small-card-top)。
// R21 正是"不可追溯"本身的硬拦截:应渲染却无 node-id = 违规。
//
// "应生成独立 DOM"的节点(满足任一):
// - TEXT 节点
// - autolayout 容器(layoutMode ∈ {HORIZONTAL, VERTICAL})
// - layoutPositioning === 'ABSOLUTE'(需 R20 校验坐标)
// - name 前缀 img- / btn- / input-(生成 <img>/<button>/<input>)
// 排斥:
// - _inBakedSubtree(bg-/img- 整体切图 或 x- 忽略子树,本就不出 DOM)
// - _hidden(不渲染)
// - _templateDup(.map() 数据副本,只需代表项挂 id)
// - name 前缀 bg- / bgc- / x-(自身不生成独立 DOM:bg/bgc 挂父,x 忽略)
//
// .map() 模板项:产物用代表项(variant a)nodeId 挂 data-node-id;R21 对代表项校验,
// 副本已被 _templateDup 跳过。
export const id = 'R21';
export const name = 'node-id-coverage';
const NO_OWN_DOM_PREFIXES = ['bg-', 'bgc-', 'x-'];
export function check({ cache, product }) {
const violations = [];
for (const [nodeId, node] of Object.entries(cache.nodes)) {
if (node._inBakedSubtree || node._hidden || node._templateDup) continue;
const nm = (node.name || '').trim();
if (NO_OWN_DOM_PREFIXES.some((p) => nm.startsWith(p)) || nm === 'bg' || nm === 'bgc' || nm === 'x') continue;
if (!shouldRender(node, nm)) continue;
if (!hasDomNode(product, nodeId)) {
violations.push({
rule: id,
nodeId,
name: node.name || '(no name)',
type: node.type,
expected: `应生成 DOM 的节点必须挂 data-node-id="${nodeId}"(§5.1.1 铁律;.map() 模板挂代表项 id),否则 R06/R18/R19/R20 无法绑定校验`,
actual: '产物 JSX 中找不到该 nodeId 的 data-node-id(不可追溯,可能漏画或漏挂 id)',
file: '(missing in jsx)',
line: 0,
snippet: '',
});
}
}
return violations;
}
function shouldRender(node, nm) {
if (node.type === 'TEXT') return true;
if (node.layoutMode === 'HORIZONTAL' || node.layoutMode === 'VERTICAL') return true;
if (node.layoutPositioning === 'ABSOLUTE') return true;
if (nm.startsWith('img-') || nm.startsWith('btn-') || nm.startsWith('input-')) return true;
return false;
}
function hasDomNode(product, nodeId) {
const idNorm = nodeId.replace(/:/g, '-');
const re = new RegExp(`data-node-id=(?:"|\\{['"])(?:${escapeRegex(nodeId)}|${escapeRegex(idNorm)})(?:"|['"]\\})`);
for (const j of product.jsx) {
if (re.test(j.content)) return true;
}
return false;
}
function escapeRegex(s) {
return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
}
#!/usr/bin/env node
// slugify.mjs — pp-d2c v1.1.0 内置 slug 工具
//
// 用法:
// node slugify.mjs "<name>" → 输出 slug
// node slugify.mjs "<name>" --fallback "<nodeId>" → 失败时用 page-<nodeId-safe> 兜底
//
// slug 规则:
// 1. ASCII: 保留 [a-z0-9], 其余 → '-', trim, lowercase, 连续 '-' 压成一个
// 2. 中文 → pinyin 简易转换 (内置常用字表, 缺则跳过)
// 3. 结果为空 or 仅含 '-' → 返回 fallback 或空串
//
// 中文覆盖: 前 500 常用字 + Figma 常见图层词(活动、页面、按钮、导航等)
// 不追求 100% 覆盖,只作 v1.1.0 默认 slug 兜底;用户可显式指定 slug 覆盖脚本结果
const CJK_TO_PINYIN = {
// 数字 / 单位
'零': 'ling', '一': 'yi', '二': 'er', '三': 'san', '四': 'si', '五': 'wu',
'六': 'liu', '七': 'qi', '八': 'ba', '九': 'jiu', '十': 'shi',
'百': 'bai', '千': 'qian', '万': 'wan', '亿': 'yi',
// 常用字 (Figma 图层高频词)
'完': 'wan', '整': 'zheng', '版': 'ban', '页': 'ye', '面': 'mian',
'中': 'zhong', '国': 'guo', '秋': 'qiu', '春': 'chun', '夏': 'xia', '冬': 'dong',
'节': 'jie', '日': 'ri', '月': 'yue', '年': 'nian', '季': 'ji',
'亚': 'ya', '洲': 'zhou', '欧': 'ou', '美': 'mei', '非': 'fei', '大': 'da',
'洋': 'yang', '海': 'hai', '陆': 'lu', '岛': 'dao',
'首': 'shou', '尾': 'wei', '前': 'qian', '后': 'hou', '左': 'zuo', '右': 'you',
'上': 'shang', '下': 'xia', '内': 'nei', '外': 'wai',
'状': 'zhuang', '态': 'tai', '栏': 'lan', '条': 'tiao', '框': 'kuang',
'按': 'an', '钮': 'niu', '键': 'jian', '标': 'biao', '题': 'ti', '副': 'fu',
'图': 'tu', '片': 'pian', '像': 'xiang', '标': 'biao', '识': 'shi',
'文': 'wen', '字': 'zi', '本': 'ben', '段': 'duan',
'导': 'dao', '航': 'hang', '菜': 'cai', '单': 'dan', '侧': 'ce', '边': 'bian',
'底': 'di', '部': 'bu', '顶': 'ding',
'活': 'huo', '动': 'dong', '优': 'you', '惠': 'hui', '券': 'quan', '卡': 'ka',
'福': 'fu', '利': 'li', '专': 'zhuan', '享': 'xiang',
'领': 'ling', '取': 'qu', '立': 'li', '即': 'ji', '抢': 'qiang',
'预': 'yu', '约': 'yue', '订': 'ding', '购': 'gou', '买': 'mai',
'火': 'huo', '车': 'che', '票': 'piao', '飞': 'fei', '机': 'ji', '船': 'chuan',
'酒': 'jiu', '店': 'dian', '住': 'zhu', '宿': 'su',
'开': 'kai', '售': 'shou', '关': 'guan', '闭': 'bi', '结': 'jie', '束': 'shu',
'时': 'shi', '间': 'jian', '倒': 'dao', '计': 'ji',
'新': 'xin', '旧': 'jiu', '老': 'lao', '客': 'ke', '户': 'hu',
'会': 'hui', '员': 'yuan',
'首': 'shou', '页': 'ye', '主': 'zhu', '要': 'yao',
'服': 'fu', '务': 'wu', '中': 'zhong', '心': 'xin',
'登': 'deng', '录': 'lu', '注': 'zhu', '册': 'ce', '账': 'zhang', '号': 'hao',
'密': 'mi', '码': 'ma', '手': 'shou', '机': 'ji',
'搜': 'sou', '索': 'suo', '查': 'cha', '询': 'xun',
'折': 'zhe', '扣': 'kou', '价': 'jia', '元': 'yuan', '角': 'jiao', '分': 'fen',
'免': 'mian', '费': 'fei',
'收': 'shou', '藏': 'cang', '分': 'fen', '享': 'xiang',
'设': 'she', '置': 'zhi', '编': 'bian', '辑': 'ji', '删': 'shan', '除': 'chu',
'保': 'bao', '存': 'cun', '确': 'que', '认': 'ren',
'成': 'cheng', '功': 'gong', '失': 'shi', '败': 'bai', '错': 'cuo', '误': 'wu',
'返': 'fan', '回': 'hui', '进': 'jin', '入': 'ru', '出': 'chu',
'快': 'kuai', '慢': 'man', '高': 'gao', '低': 'di',
'弹': 'tan', '窗': 'chuang', '浮': 'fu', '动': 'dong',
'空': 'kong', '白': 'bai', '黑': 'hei', '红': 'hong', '橙': 'cheng', '黄': 'huang',
'绿': 'lv', '蓝': 'lan', '紫': 'zi', '灰': 'hui',
'预': 'yu', '警': 'jing', '提': 'ti', '示': 'shi',
};
export function slugify(input, fallbackNodeId = null) {
if (!input || typeof input !== 'string') {
return fallbackNodeId ? nodeIdFallback(fallbackNodeId) : '';
}
// 1. 中文转 pinyin (逐字, 缺字直接丢弃)
let s = '';
for (const ch of input) {
if (CJK_TO_PINYIN[ch]) {
s += CJK_TO_PINYIN[ch];
} else {
s += ch;
}
}
// 2. ASCII 化: 保留 [A-Za-z0-9], 其余变 '-'
s = s.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '');
// 3. 结果空 or 只有 '-' → fallback
if (!s || /^-+$/.test(s)) {
return fallbackNodeId ? nodeIdFallback(fallbackNodeId) : '';
}
return s;
}
function nodeIdFallback(nodeId) {
const safe = String(nodeId).replace(/[^A-Za-z0-9]+/g, '_');
return `page-${safe}`;
}
// CLI
import { fileURLToPath } from 'node:url';
import path from 'node:path';
const __filename = fileURLToPath(import.meta.url);
if (path.resolve(process.argv[1] || '') === __filename) {
const argv = process.argv.slice(2);
let name = null;
let fallback = null;
for (let i = 0; i < argv.length; i++) {
if (argv[i] === '--fallback') fallback = argv[++i];
else if (!name) name = argv[i];
}
if (!name) {
process.stderr.write('Usage: slugify.mjs "<name>" [--fallback "<nodeId>"]\n');
process.exit(2);
}
const out = slugify(name, fallback);
process.stdout.write(out + '\n');
}
# R01 - fixed-position
## 判定归属
- **硬防线** (check-rules.mjs 自动拦截): ✅
- **软防线** (Rule-Scan sub-agent 识别): ✅ (兜底)
- **排斥条件**: 无(与 R14 fixed-z-index 是"父规则-补充规则"关系,不排斥)
## 触发条件
- **cache**: `node.name.startsWith('fixed-')`
- **命中信号**: 图层名以 `fixed-` 开头(如 `fixed-状态栏`、`fixed-topbar`、`fixed-底部bar`)
## 期望产物
**JSX 端**:
- 该节点对应的 `<div>` / `<section>` 必须有 `className` 且带 `data-node-id`
**SCSS 端**:
- 对应 className 规则内 **必须含** `position: fixed;`
- 同时按 `constraints` 推 `top` / `left` / `right` / `bottom`:
- `constraints.vertical === 'TOP'` → `top: 0;`
- `constraints.vertical === 'BOTTOM'` → `bottom: 0;`
- `constraints.horizontal === 'LEFT'` → `left: 0;`
- `constraints.horizontal === 'RIGHT'` → `right: 0;`
**反例扫描**:
- `.<className> { position: relative/static/absolute; ... }` 而不是 `fixed`
- 缺 `position` 属性
- 只在父容器上写 `position: relative`,自身没写 `fixed`
## 反例 (agent 常见错法)
```scss
/* ❌ 错法 */
.topbar {
position: relative;
width: 750px;
height: 236px;
}
/* ❌ 错法 (缺 position) */
.topbar {
width: 750px;
height: 236px;
}
```
## 落地代码模板
```jsx
<div className={styles.topbar} data-node-id="211:32">
{/* ... */}
</div>
```
```scss
.topbar {
position: fixed;
top: 0;
left: 0;
width: 750px;
height: 236px;
z-index: 100; // 见 R14
}
```
## 违反后果
- **产物表现**: 页面滚动时该组件跟随滚动,不再"钉"在视口顶/底/左/右
- **典型事故**: v0.3.20 test1 事故 — `fixed-状态栏` 只写了 `position: relative`,滚动后状态栏消失
## 相关
- SKILL.md §4.3 切图四条硬规则
- rules/R14-fixed-z-index.md
# R02 - fills-image
## 判定归属
- **硬防线** (check-rules.mjs 自动拦截): ✅
- **软防线** (Rule-Scan sub-agent 识别): ✅ (兜底)
- **排斥条件**:
- 节点前缀是 `x-` → 忽略
- 节点是 `btn-` 子层的 `bgc-` → 归 R09
- 节点已被 R11 判 mask-vector-css-able → R02 优先切图
## 触发条件
- **cache**: `Array.isArray(node.fills) && node.fills.some(f => f.type === 'IMAGE' && f.visible !== false)`
- **命中信号**: 该节点有可见的 IMAGE 填充
## 期望产物
**assets.txt 端**:
- 必须记录该 `nodeId` 的切图文件名(如 `bg-body.png`)
**JSX 端** (二选一):
- (a) `<img src="${ASSET_PREFIX}xxx.png" data-node-id="{id}" />` (前景图)
- (b) 父容器 `<div className={styles.foo} data-node-id="{id}"></div>`(后台图,配合 SCSS background)
**SCSS 端** (方式 b 时):
- `.foo { background-image: url("...xxx.png"); background-size: cover|100% 100%; }`
**反例扫描**:
- assets.txt 缺记录 → violation
- assets.txt 有记录但产物没引用 → violation
## 反例 (agent 常见错法)
```jsx
{/* ❌ 错法 1: 该切图不切,凭空 gradient */}
<div style={{ background: "linear-gradient(180deg, #fee 0%, #fdb 100%)" }} />
{/* ❌ 错法 2: 该切图不切,写 solid color */}
<div className={styles.bgBody} />
```
```scss
/* ❌ 错法 2 对应 */
.bgBody {
background-color: #f0e0d0;
}
```
## 落地代码模板
**前景图**:
```jsx
<img className={styles.heroImg}
src={`${ASSET_PREFIX}hero.png`}
data-node-id="211:126"
alt="" />
```
**背景图(推荐用父容器)**:
```jsx
<div className={styles.bgBody} data-node-id="211:37">
{/* children */}
</div>
```
```scss
.bgBody {
width: 750px;
height: 1200px;
background-image: url("../../static/test1/bg-body.png");
background-size: 100% 100%;
background-repeat: no-repeat;
}
```
## 违反后果
- **产物表现**: agent 凭空搓 gradient/solid color 代替真图,视觉严重跑偏
- **典型事故**:
- v0.3.20 test1 事故 — `bg-body` (211:37) 没切图,agent 编造 `background-color`
- v0.3.21 test8 事故 — 多张 `image XX` 未切,agent 用文字堆叠模拟
## 相关
- SKILL.md §4.3 切图四条硬规则
- rules/R08-bg-landing-form.md (bg- 前缀的落地形态)
- rules/R09-btn-bgc-取值.md (btn 内 bgc 的取值)
# R03 - implicit-image
## 判定归属
- **硬防线** (check-rules.mjs 自动拦截): ❌ (语义判断,脚本难)
- **软防线** (Rule-Scan sub-agent 识别): ✅ (**唯一识别方**)
- **排斥条件**:
- 节点有 `img-` / `bg-` / `bgc-` / `x-` / `input-` / `sub-` / `block-` / `btn-` / `fixed-` / `end-` / `scrollx-` / `scrolly-` 前缀 → 不适用
- 子树中有 TEXT / INSTANCE / COMPONENT 节点 → 不适用 (需交互或文字,不该切)
- 子树中有以 btn- / input- / sub- / block- 命名的子节点 → 不适用
## 触发条件
**同时满足**:
1. `node.name` **无**上述任何前缀
2. 整棵子树的节点 `type` 全在 `['VECTOR', 'BOOLEAN_OPERATION', 'RECTANGLE', 'ELLIPSE', 'STAR', 'REGULAR_POLYGON', 'LINE']`
3. 子树中**无** `TEXT` / `INSTANCE` / `COMPONENT` 类型
4. 子树中**无** `btn-` / `input-` / `sub-` / `block-` 前缀命名的子节点
## 期望产物
**assets.txt 端**:
- 必须为该节点整体切一张图,记录 `nodeId → filename`
**JSX 端**:
- `<img className={styles.foo} src="${ASSET_PREFIX}foo.png" data-node-id="{id}" alt="" />`
**SCSS 端**:
- 只写尺寸和定位,**不要**展开成一堆 vector 元素
**反例扫描**:
- agent 不切图,展开成 20+ 个 `<div>` + `<svg>` + CSS 渐变叠加
- SCSS 里出现大量 `.icon-part-1 { ... } .icon-part-2 { ... }` 类的重复子元素规则
## 反例 (agent 常见错法)
```jsx
{/* ❌ 错法: 该整体切图但没识别,展开成 vector CSS 堆 */}
<div className={styles.iconWrap} data-node-id="123:45">
<div className={styles.iconLayer1} />
<div className={styles.iconLayer2} />
<svg><path d="M..." /></svg>
<div className={styles.iconLayer3} />
</div>
```
## 落地代码模板
```jsx
<img className={styles.iconWrap}
src={`${ASSET_PREFIX}icon-wrap.png`}
data-node-id="123:45"
alt="" />
```
```scss
.iconWrap {
width: 120px;
height: 120px;
}
```
## 违反后果
- **产物表现**: 该切图没切,变成一堆 vector/CSS 堆叠,几何形状不对、无法还原设计
- **典型事故**:
- v0.3.20 test2 事故 — `renwuInviteIcon` (装饰性 icon)没切,agent 用 4 层 div 叠 shadow 模拟
## Rule-Scan 识别提示
- 看 cache 里 `node.children` 递归,统计子树的 `type` 分布
- 只要出现 `TEXT` 就绝对不是 R03
- 只要出现 `btn-` / `input-` / `sub-` / `block-` 前缀的子节点(说明是复合结构,不是纯装饰)也不是 R03
- 常见误判:装饰性圆点、装饰花纹被误识别为需要展开的元素
## 相关
- SKILL.md §4.3 切图四条硬规则 (R3 隐式切图)
- rules/R11-mask-vector-css-able.md (相似判断,更严格)
# R04 - text-gradient
## 判定归属
- **硬防线** (check-rules.mjs 自动拦截): ❌
- **软防线** (Rule-Scan sub-agent 识别): ✅ (**唯一识别方**)
- **排斥条件**:
- 末位可见 fill 是 SOLID → 归 R06
- 末位可见 fill 是 IMAGE 且节点不是 TEXT → 归 R02
## 触发条件
- **cache**: `node.type === 'TEXT'`
- **fills 末位可见** (从 `fills.length-1` 倒序找第一个 `visible !== false`) 的类型是:
- `GRADIENT_LINEAR` / `GRADIENT_RADIAL` / `GRADIENT_ANGULAR` / `GRADIENT_DIAMOND`
- `IMAGE`
## 期望产物
**JSX 端** (强制):
```jsx
<div className={styles.title} data-node-id="{id}">
<span>2026</span>
</div>
```
**SCSS 端** (强制):
```scss
.title {
span {
background: linear-gradient(180deg, #FFF7EE 0%, #FFDBAA 100%);
background-clip: text;
-webkit-background-clip: text;
color: transparent;
}
}
```
**关键点**:
- `<span>` 是**必须的**(background-clip 需要行内元素承载)
- `background` 后必须紧跟 `background-clip: text` + `color: transparent`
- 缺任何一项都会失效(要么整块背景、要么无渐变)
## Handle → CSS angle 转换表
Figma `gradientHandlePositions` 有 3 个点:`P0=起点, P1=终点, P2=侧向控制点`。
| 常见 handle (P0→P1) | CSS angle | 视觉方向 |
|---|---|---|
| `[0.5, 0] → [0.5, 1]` | `180deg` | 从上到下 |
| `[0.5, 1] → [0.5, 0]` | `0deg` | 从下到上 |
| `[0, 0.5] → [1, 0.5]` | `90deg` | 从左到右 |
| `[1, 0.5] → [0, 0.5]` | `270deg` | 从右到左 |
| `[0, 0] → [1, 1]` | `135deg` | 左上到右下 |
| `[1, 0] → [0, 1]` | `225deg` | 右上到左下 |
**通用公式**:
```
angle_rad = atan2(P1.x - P0.x, P0.y - P1.y) // 注意 y 反向
angle_deg = angle_rad * 180 / π
// 结果映射到 CSS 0..360
```
## 反例 (agent 常见错法)
```scss
/* ❌ 错法 1: 凭空搓 solid color 代替 gradient */
.title { color: #FFDBAA; }
/* ❌ 错法 2: 写 background 但没 background-clip */
.title {
background: linear-gradient(180deg, #FFF7EE 0%, #FFDBAA 100%);
/* 缺 background-clip: text 和 color: transparent → 整个块被染色,文字看不见 */
}
/* ❌ 错法 3: 没包 <span>,直接给 .title 挂 background-clip → Safari 部分场景失效 */
```
## 落地代码模板
**GRADIENT_LINEAR**:
```jsx
<div className={styles.title2026} data-node-id="211:411 > 211:91">
<span>2026</span>
</div>
```
```scss
.title2026 {
font-size: 48px;
font-weight: bold;
span {
background: linear-gradient(180deg, #FFF7EE 0%, #FFDBAA 100%);
background-clip: text;
-webkit-background-clip: text;
color: transparent;
}
}
```
**IMAGE fill**:
```jsx
<div className={styles.brandText} data-node-id="{id}">
<span>品牌名</span>
</div>
```
```scss
.brandText span {
background: url("../../static/xxx/brand-fill.png") center / cover;
background-clip: text;
-webkit-background-clip: text;
color: transparent;
}
```
## 违反后果
- **产物表现**: 文字渐变/图案填充丢失,变成纯色或整块背景
- **典型事故**:
- v0.3.21 test8 事故 — `2026 (TEXT)` 应该白到金渐变,agent 写成 `color: white`
## Rule-Scan 识别提示
- 只判 TEXT 节点
- fills 数组末位可见类型判断:
```js
const lastVisible = fills.slice().reverse().find(f => f && f.visible !== false);
if (lastVisible && (lastVisible.type.startsWith('GRADIENT') || lastVisible.type === 'IMAGE')) → 命中 R04
```
- 输出 context 里必须带 `fills_last_type` / `fills_last_stops` / `fills_last_handles`,UI sub-agent 直接照做
## 相关
- SKILL.md §4.1.1 TEXT 多层 fills 处理
- rules/R06-text-solid-last.md (末位 SOLID 的对应规则)
# R05 - space-between
## 判定归属
- **硬防线** (check-rules.mjs 自动拦截): ✅
- **软防线** (Rule-Scan sub-agent 识别): ✅ (兜底)
- **排斥条件**: 无
## 触发条件
- **cache**: `node.primaryAxisAlignItems === 'SPACE_BETWEEN'`
- **命中信号**: Figma AutoLayout 主轴对齐 = SPACE_BETWEEN (常见于 topbar 左右两端布局、卡片 header 主题+图标)
## 期望产物
**JSX 端**:
- 父容器 flex 布局(结构不用变,子元素两个及以上)
**SCSS 端** (强制):
```scss
.container {
display: flex;
flex-direction: row; // 或 column,看 layoutMode
justify-content: space-between;
align-items: center;
}
```
**反例扫描** (warning 而非 violation):
- `margin-left: auto` / `margin-right: auto` 模拟推开
- `justify-content: flex-end` + 手动 padding
- `gap: auto`(不合法但 agent 常用)
## 反例 (agent 常见错法)
```scss
/* ❌ 错法 1: 用 margin auto */
.topbar {
display: flex;
.navBack { margin-right: auto; }
.navShare { }
}
/* ❌ 错法 2: 用 flex-end 单侧对齐 */
.topbar {
display: flex;
justify-content: flex-end;
padding-left: 500px; /* 硬 padding 顶开左边元素 */
}
/* ❌ 错法 3: 用 gap auto */
.topbar {
display: flex;
gap: auto; /* 不合法 */
}
```
## 落地代码模板
```scss
.topbar {
display: flex;
flex-direction: row;
justify-content: space-between;
align-items: center;
padding: 0 20px;
width: 750px;
height: 118px;
}
```
## 违反后果
- **产物表现**: 布局在不同宽度设备上错位(margin auto 只在 flex 里工作,固定 padding 无响应)
- **典型事故**:
- test8 topbar — 左右两端图标应 SPACE_BETWEEN,agent 写成 margin-right: auto,设备变宽后错位
## Rule-Scan 识别提示
- `node.primaryAxisAlignItems === 'SPACE_BETWEEN'` 是**唯一触发**
- 有些 AutoLayout 主轴是 vertical (`node.layoutMode === 'VERTICAL'`),需 `flex-direction: column`
## 相关
- SKILL.md §4.3 硬规则
# R06 - text-solid-last
## 判定归属
- **硬防线** (check-rules.mjs 自动拦截): ✅
- **软防线** (Rule-Scan sub-agent 识别): ✅ (兜底)
- **排斥条件**:
- 末位可见 fill 是 GRADIENT/IMAGE → 归 R04
- fills 全部 invisible → 用默认 `#000000`
## 触发条件
- **cache**: `node.type === 'TEXT' && Array.isArray(node.fills) && fills.length > 0`
- **fills 末位可见** (`fills.slice().reverse().find(f => f && f.visible !== false)`) 类型是 `SOLID`
## 期望产物
**SCSS 端**:
- 对应 CSS 类含 `color: #RRGGBB;`,值来自末位可见 SOLID
- HEX 大小写不敏感;alpha 通道另写 `opacity`
**取色算法**:
```js
const solid = pickLastVisibleFill(node.fills);
const { r, g, b } = solid.color; // 0..1
const hex = '#' + [r,g,b].map(n => Math.round(n*255).toString(16).padStart(2,'0')).join('');
// 例: { r: 0, g: 0.4, b: 0.6 } → #006699
```
**反例扫描**:
- 取到 fills[0] 或中间层的色(不是末位可见)
- 编造的色(cache 里找不到 SOLID.color 对应值)
## 反例 (agent 常见错法)
```scss
/* 若 cache 里 fills = [{SOLID, #FFFFFF, visible:true}, {SOLID, #00679D, visible:true}]
末位可见 = #00679D,应写:
✅ color: #00679D
❌ color: #FFFFFF (取到 fills[0]) */
/* 若 cache 里 fills = [{SOLID, #00679D, visible:false}, {SOLID, #FF0000, visible:true}]
末位可见 = #FF0000,应写:
✅ color: #FF0000
❌ color: #00679D (取到 fills[0] 忽略 visible) */
```
## 落地代码模板
```jsx
<span className={styles.priceLabel} data-node-id="211:50">
提前下预约单开售自动抢
</span>
```
```scss
.priceLabel {
color: #00679D; // 来自 cache node.fills 末位可见 SOLID
font-size: 28px;
line-height: 1.4;
}
```
## 违反后果
- **产物表现**: 文字颜色错误,与设计稿不符
- **典型事故**:
- v0.3.20 test1 事故 — TEXT 节点 fills 有两层可见,agent 取到 fills[0] (#FFF),漏了末位 (#00679D)
## Rule-Scan 识别提示
- 优先看末位 (`fills.length - 1`)倒序遍历
- 跳过 `visible === false` 的层
- 若 fills 全部 invisible → 走默认 `#000000` (不常见,但要覆盖)
## 相关
- SKILL.md §4.1.1 TEXT 多层 fills 处理
- rules/R04-text-gradient.md (末位是 GRADIENT/IMAGE 的对应规则)
- rules/R10-no-fake-solid-color.md (核对色源,避免幻觉色)
# R07 - multi-fills
## 判定归属
- **硬防线** (check-rules.mjs 自动拦截): ❌
- **软防线** (Rule-Scan sub-agent 识别): ✅ (**唯一识别方**)
- **排斥条件**:
- fills 只有 1 层可见 → 不适用
- fills 全部同类型 SOLID → 属 R06 单层判定
- 节点是 TEXT + 末位 GRADIENT/IMAGE → 归 R04
## 触发条件
- **cache**: `Array.isArray(node.fills) && fills.filter(f => f && f.visible !== false).length >= 2`
- **且**: 多层 fills 类型混合 (`SOLID + IMAGE`、`SOLID + GRADIENT`、多个 IMAGE 叠加等)
## 期望产物
**核心原则**: 每层 fills 都要落地,不能只取其一。
**JSX/SCSS 组合方式**:
1. **SOLID + IMAGE 叠加** (底色 + 图案):
```scss
.box {
background-color: #FF6600; // SOLID 底色
background-image: url("...pattern.png"); // IMAGE 上层
background-blend-mode: normal; // 或 multiply / overlay
}
```
2. **SOLID + GRADIENT** (底色 + 渐变):
```scss
.box {
background:
linear-gradient(180deg, rgba(0,0,0,0) 0%, rgba(0,0,0,0.6) 100%), // GRADIENT 上层
#FF6600; // SOLID 底色
}
```
3. **多层 GRADIENT / IMAGE**:
- CSS `background` 简写多层,后写的在下方
- **Figma fills 数组顺序**: 索引小的在下,索引大的在上;转 CSS 时**颠倒**
## 反例 (agent 常见错法)
```scss
/* Figma fills = [{SOLID, #FF6600, visible:true}, {IMAGE, pattern.png, visible:true}]
agent 常错法: */
/* ❌ 只写 SOLID 忽略 IMAGE */
.box { background-color: #FF6600; }
/* ❌ 只写 IMAGE 忽略 SOLID */
.box { background-image: url("...pattern.png"); }
/* ❌ 层次颠倒 (SOLID 在上盖住 IMAGE) */
.box {
background-image: url("...pattern.png");
background-color: #FF6600;
}
/* 上例其实 CSS 顺序不重要,但要用 background 简写才可控叠加 */
```
## 落地代码模板
```scss
.orangeCard {
background:
url("../../static/xxx/pattern.png") center / cover no-repeat,
#FF6600;
width: 750px;
height: 200px;
}
```
## 违反后果
- **产物表现**: 底色或图案丢失,视觉与设计不符
- **典型事故**:
- v0.3.19 test8 事故 — `quanItem__btn` fills = [SOLID 金色, IMAGE 光斑],agent 只写 SOLID,光斑丢失
## Rule-Scan 识别提示
- 统计 `visible !== false` 的 fills 数量,≥2 才触发
- 输出 context 里必须列 **每一层** 的 type + 主要参数(color/imageRef/gradientStops),UI sub-agent 照做
## 相关
- SKILL.md §4.3 CSS 翻译表
- rules/R09-btn-bgc-取值.md (btn 内 bgc 层的特殊处理)
# R08 - bg-landing-form
## 判定归属
- **硬防线** (check-rules.mjs 自动拦截): ✅
- **软防线** (Rule-Scan sub-agent 识别): ✅ (兜底)
- **排斥条件**: 无(反向匹配,自成一体)
## 触发条件
- **cache**: `node.name.startsWith('bg-')` 或 `node.name === 'bg'`
- **命中信号**: 图层名以 `bg-` 开头(如 `bg-body`、`bg-header`、`bg-card`)或裸 `bg`
## 期望产物
**核心原则**: `bg-` 前缀节点表示"该节点是父容器的背景",**必须以父容器的 `background-image` 方式落地**。
**JSX 端** (强制):
- **不**为 bg 节点单独渲染 `<div>` 或 `<img>`
- **父容器** 直接挂 `className` 并有 `data-node-id="{bgNodeId}"` (代表这层背景由 bg 节点提供)
**SCSS 端**:
- **父容器类**含 `background-image: url("...bg-xxx.png")` + `background-size: cover|100% 100%`
**禁止的落地形态** (反向扫描):
| 错法 | 表现 | 检测正则 |
|---|---|---|
| `<img src="bg-xxx.png">` | 把 bg 当前景图 | `/<img[^>]*src=[^>]*bg-/` |
| `<img src=".../bg.png">` (裸 bg) | 同上 | `/<img[^>]*src=[^>]*\/bg\.[a-z]+/` |
| `style={{ background: ... }}` | inline style | `/style=\{\{[^}]*background/` |
| `.foo::before { background-image }` | 伪元素挂 bg | `/::(before\|after)\s*\{[^}]*background-image/` |
| `<div className={styles.bg} />` (空 div) | 空 div 挂 bg | `/<div[^>]*className=\{styles\.bg[^}]*\}[^>]*\/>/` |
## 反例 (agent 常见错法)
```jsx
{/* ❌ 错法 1: bg 用 <img> */}
<img src={`${ASSET_PREFIX}bg-body.png`} className={styles.bgBody} />
{/* ❌ 错法 2: inline style */}
<div style={{ backgroundImage: `url(${ASSET_PREFIX}bg-body.png)` }} />
{/* ❌ 错法 3: 空 div 挂 bg */}
<div className={styles.bgBody} data-node-id="211:37" />
{/* 然后在其他 div 里塞内容,分开的父子关系 */}
```
```scss
/* ❌ 错法 4: 伪元素 */
.page::before {
content: "";
background-image: url("...bg-body.png");
position: absolute;
inset: 0;
}
```
## 落地代码模板
```jsx
<div className={styles.page} data-node-id="211:31">
{/* 父容器 page 承担 bg-body 的背景, 无需为 bg-body 单开元素 */}
<div className={styles.topbar} data-node-id="211:32">...</div>
<div className={styles.content}>...</div>
</div>
```
```scss
.page {
background-image: url("../../static/test1/bg-body.png");
background-size: 100% 100%;
background-repeat: no-repeat;
width: 750px;
min-height: 100vh;
}
```
## 违反后果
- **产物表现**:
- `<img>` 挂 bg → 图片和内容 z-index 冲突,内容被压在图后
- 伪元素挂 bg → 父容器 `position: relative` 忘配时定位错
- inline style → 无缓存、无响应式,后期难维护
- **典型事故**:
- v0.3.9 test8 事故 — `bg-body` 用 `<img>` + `position: absolute`,内容层被盖住
## Rule-Scan 识别提示
- 触发只看图层名(`bg-` 前缀 or 裸 `bg`)
- 判定产物形态需 Read jsx/scss,对照上表 5 种禁止形态
- **警惕**: 有些 agent 会把 bg 分层到子块内(不是 page 而是 sub- 或 block-),这时候 bg 应挂到那个子块的父容器
## 相关
- SKILL.md §4.3 硬规则第 4 条
- rules/R02-fills-image.md (fills IMAGE 的通用落地)
# R09 - btn-bgc-取值
## 判定归属
- **硬防线** (check-rules.mjs 自动拦截): ❌
- **软防线** (Rule-Scan sub-agent 识别): ✅ (**唯一识别方**)
- **排斥条件**:
- `bgc-` 层的 fills 只有单层 SOLID → 直接按 CSS `background-color` 取,不算 R09
- `bgc-` 层的 fills 是 IMAGE → 归 R02
- btn 前缀节点无 `bgc-` 子层 → 不适用
## 触发条件
- **cache**: 存在 `node.name.startsWith('btn-')` 节点
- **且**: 该节点 children 里有 `child.name.startsWith('bgc-')`
- **且**: `bgc-` 层的 `fills` 是 `GRADIENT_*`(或多层含 GRADIENT)
## 期望产物
**核心原则**: `btn-` 的**父容器** CSS `background` 应取自 `bgc-` 子层的**真实 fills**,不是编造。
**JSX 端**:
- 按分层结构 `<div className={styles.btn}>` 承担 bgc 背景,内部再放文字/图标
**SCSS 端**:
- `.btn { background: linear-gradient(...); }`(gradient 参数按 bgc-*.fills)
**反例扫描**:
- agent 编造渐变色(cache 里 bgc- 明明是 A→B,产物写成 C→D)
- agent 编造 solid color 代替 gradient
## 反例 (agent 常见错法)
```scss
/* Figma:
btn-primary
└─ bgc-primary fills = [ GRADIENT: #864500 → #6D3600 ]
└─ text "购买"
agent 常错法: */
/* ❌ 错法 1: 凭空搓 solid color */
.btnPrimary { background-color: #864500; }
/* ❌ 错法 2: 颜色对了但类型错(SOLID 代 GRADIENT) */
.btnPrimary { background: #864500; }
/* ❌ 错法 3: 编造与 bgc 无关的渐变 */
.btnPrimary { background: linear-gradient(180deg, #FF0 0%, #F00 100%); }
```
## 落地代码模板
```jsx
<button className={styles.btnPrimary} data-node-id="211:446">
<span>购买</span>
</button>
```
```scss
.btnPrimary {
background: linear-gradient(180deg, #864500 0%, #6D3600 100%); // 取自 bgc-primary.fills
border: none;
border-radius: 44px;
padding: 22px 60px;
color: #FFFFFF;
font-size: 32px;
}
```
## 违反后果
- **产物表现**: 按钮颜色错、无渐变
- **典型事故**:
- v0.3.20 test1 事故 — `btn-购买` 内 `bgc-` 渐变 #864500→#6D3600,agent 只写 SOLID #864500
- v0.3.19 test8 事故 — `quanItem__btn` 内 `bgc-` 光效渐变,agent 编造成完全不同的黄色
## Rule-Scan 识别提示
- 先按 `btn-` 前缀找到按钮节点
- 递归 children 找 `bgc-` 前缀节点(通常是第 1 或第 2 层)
- 读该 bgc 节点的 `fills` (**不是** btn 节点自身的 fills)
- 输出 context 里必须含:
```json
{
"btn_nodeId": "211:446",
"bgc_nodeId": "211:447",
"bgc_fills": [ { "type": "GRADIENT_LINEAR", "gradientStops": [...], "gradientHandlePositions": [...] } ]
}
```
## 相关
- SKILL.md §4.3 CSS 翻译表 (fills GRADIENT)
- rules/R07-multi-fills.md
- rules/R10-no-fake-solid-color.md
# R10 - no-fake-solid-color
## 判定归属
- **硬防线** (check-rules.mjs 自动拦截): ❌ (需交叉核对 cache 与产物)
- **软防线** (Rule-Scan sub-agent 识别): ✅ (**唯一识别方**)
- **排斥条件**:
- R06 已判定过的 TEXT color 值 → 不重复扫
- R07/R09 已判定过的 background 色 → 不重复扫
## 触发条件
- **产物**: SCSS 中出现 `color: #XXX` / `background-color: #XXX` / `background: #XXX` 之类的 SOLID 色
- **cache 侧**: 该 CSS 规则对应的 `nodeId` 的所有 fills 中,**找不到**任何 SOLID 色的 HEX 与该产物色匹配
- **意味着**: agent 幻觉搓色
## 期望产物
**核心原则**: 产物里出现的每一个 `#RRGGBB` 都必须在 cache 里能找到 fills 源头。
**判定算法**:
1. Read 产物所有 .scss / .css 文件
2. 提取所有 `#RRGGBB` (或 `rgba(...)` 已知色)
3. 对每个色,反查:该规则挂在哪个 `nodeId` 的 className 下
4. Read 该 nodeId 的 cache,遍历 fills:
- 有匹配 SOLID.color → OK
- 全部 GRADIENT/IMAGE → 走 R04/R07/R09,不属 R10
- 找不到匹配 → **R10 命中(幻觉色)**
## 反例 (agent 常见错法)
```scss
/* Figma nodeId=211:32 fills = [] (无填充,靠父容器)
agent 幻觉搓: */
.topbar { background-color: #F5F5F5; } /* ❌ cache 里找不到 #F5F5F5 */
/* Figma nodeId=211:411 fills = [{SOLID, #003366, visible:true}]
agent 幻觉搓: */
.title { color: #0066CC; } /* ❌ #0066CC ≠ #003366 */
```
## 落地代码模板
**从 cache 精确取色**:
```js
// UI sub-agent 生 SCSS 前,遍历本 block 每个 TEXT/RECT 节点:
const solid = pickSolidColor(node.fills); // 取第一个可见 SOLID
if (!solid) return; // 无 SOLID 就不写色
const hex = rgbaToHex(solid.color);
// 输出: color: {hex};
```
## 违反后果
- **产物表现**: 颜色与设计稿不符,agent 常"猜"一个相近色
- **典型事故**:
- v0.3.15 test8 事故 — 卡片背景 agent 写 #FFF7E5,cache 里 fills=[] 也没父背景,纯幻觉
- 多起 v0.3.x 事故 — 按钮 hover / disabled 状态色被 agent 编造
## Rule-Scan 识别提示
- 只对产物已 Read 后判定;主要靠"反向核对"
- 检出 `#RRGGBB` 后,反查对应 className → nodeId → cache fills
- 若 cache 里 fills=[] 但产物有色 → 强命中
- 若 cache 里 fills 全 IMAGE/GRADIENT 但产物有 SOLID 色 → 强命中
- **豁免**: 系统默认色如 `color: inherit` / `color: transparent` / `background: none`,不算 R10
## 相关
- rules/R06-text-solid-last.md (TEXT SOLID 精确取色)
- rules/R07-multi-fills.md (多层 fills 取色)
- rules/R09-btn-bgc-取值.md (btn 背景取色)
# R11 - mask-vector-css-able
## 判定归属
- **硬防线** (check-rules.mjs 自动拦截): ❌
- **软防线** (Rule-Scan sub-agent 识别): ✅ (**唯一识别方**)
- **排斥条件**:
- 节点已按 R02 (fills=IMAGE) 切图 → 不重复判
- 节点已按 R03 (implicit-image) 切图 → 不重复判
- 简单矩形 / 圆角矩形 / 圆形 / 椭圆 → CSS 可表达,不切
## 触发条件
- **cache**: 节点或其子树含以下之一:
- `type === 'BOOLEAN_OPERATION'`(布尔运算,如 UNION/INTERSECT/SUBTRACT)
- 多层 `type === 'VECTOR'` 叠加
- `node.isMask === true` 与其他节点组合
- 复杂 SVG path(非矩形/圆形)
- **且**: 该结构**不能仅用 CSS** 表达(如 `border-radius` + `background` 组合)
## 期望产物
**核心原则**: CSS 表达不了的复合几何 → 必须切图。
**JSX 端**:
- `<img className={styles.foo} src="${ASSET_PREFIX}foo.png" data-node-id="{id}" alt="" />`
**SCSS 端**:
- 只写尺寸,**不**尝试用 CSS `mask` / 多层 `clip-path` / SVG path 还原
**反例扫描**:
- SCSS 里出现 `mask-image` / `-webkit-mask` (虽然合法,但兼容性差)
- SCSS 里出现多层 `clip-path` 试图组合
- JSX 里 inline `<svg>` 复杂 path
## 反例 (agent 常见错法)
```jsx
{/* Figma 结构: BOOLEAN_OPERATION SUBTRACT (圆环减去中间空心) */}
{/* ❌ 错法 1: agent 用 mask */}
<div className={styles.ring} />
```
```scss
.ring {
width: 200px;
height: 200px;
background: #FF6600;
mask-image: radial-gradient(circle, transparent 60px, black 61px);
-webkit-mask-image: radial-gradient(circle, transparent 60px, black 61px);
}
```
```jsx
{/* ❌ 错法 2: agent 用 clip-path */}
<div className={styles.ring} style={{ clipPath: "..." }} />
{/* ❌ 错法 3: agent 用内联 SVG (性能差, 无缓存) */}
<svg>
<path d="M100,100 L200,200 ..." fill="#FF6600" />
</svg>
```
## 落地代码模板
```jsx
<img className={styles.ring}
src={`${ASSET_PREFIX}ring.png`}
data-node-id="{id}"
alt="" />
```
```scss
.ring {
width: 200px;
height: 200px;
}
```
## CSS 可表达 vs 不可表达速查
**可 CSS 表达(不切图)**:
- 纯色矩形 / 圆角矩形 → `background-color` + `border-radius`
- 单色圆形 / 椭圆 → `border-radius: 50%`
- 单向阴影 → `box-shadow`
- 单一渐变(线性/径向) → `background: linear-gradient(...)`
- 单层 border → `border`
**不可 CSS 表达(必须切图)**:
- 布尔运算(subtract/intersect/exclude)
- 多层 vector 叠加(如 icon 组合)
- 复杂 SVG path(非规则几何)
- mask 与其他 fills 组合
- 特殊纹理 / 光效
## 违反后果
- **产物表现**:
- `mask-image` 在旧浏览器失效
- 内联 `<svg>` 破坏组件树、无缓存
- 复杂 `clip-path` 兼容性差
- **典型事故**:
- v0.3.9 test8 事故 — 圆环装饰用 `mask-image`,某些手机不显示
## Rule-Scan 识别提示
- 首先看 cache 里的 `type` 分布:出现 `BOOLEAN_OPERATION` → 强命中
- 出现多个 `VECTOR` 叠加 → 命中
- 判断"CSS 可表达"的临界:如果 shape 是圆/椭圆/圆角矩形,不命中
- 输出 context 里列出复合几何的形态描述
## 相关
- SKILL.md §4.4.pre.b 子树结构禁切规则 (v0.3.9)
- rules/R02-fills-image.md
- rules/R03-implicit-image.md
# R12 - flat-mode-naming
## 判定归属
- **硬防线** (check-rules.mjs 自动拦截): ❌
- **软防线** (Rule-Scan sub-agent 识别): ✅ (**唯一识别方**)
- **排斥条件**:
- `pp-d2c.config.json` 的 `merge.mode !== 'flat'` → 不适用
- 项目使用 CSS Modules 且每个 block 独立 scss 文件 → 天然隔离,不适用
## 触发条件
- **config**: `merge.mode === 'flat'`(所有 block 产物合并到一个文件)
- **命中信号**: 多个 block 产出相同 className(如 `.title` / `.button` / `.card`),合并后互相覆盖
## 期望产物
**核心原则**: flat 模式下,className 必须带 **block 语义前缀** 才不冲突。
**命名规范**:
| 场景 | 好类名 | 坏类名 |
|---|---|---|
| topbar 的 title | `.topbarTitle` | `.title` |
| card 的 title | `.cardTitle` | `.title` |
| footer 的 button | `.footerButton` | `.button` |
| 通用容器 | `.<block>Container` | `.container` |
**JSX 端**:
- `<div className={styles.topbarTitle}>`,不是 `<div className={styles.title}>`
**SCSS 端**:
- 类选择器全都含 block 前缀
- **不用** BEM 双下划线(`.topbar__title`)反而增加复杂度;直接驼峰 camelCase 拼
## 反例 (agent 常见错法)
```jsx
{/* Block 1: topbar */}
<div className={styles.title}>标题</div>
{/* Block 2: card */}
<div className={styles.title}>卡片标题</div>
{/* 合并后 `.title` 只保留最后一个规则,前面的样式被覆盖 */}
```
```scss
/* Block 1 生成 */
.title { font-size: 32px; color: #003366; }
/* Block 2 生成 */
.title { font-size: 24px; color: #666; }
/* flat 合并:第二个 .title 覆盖第一个,topbar 的样式丢失 */
```
## 落地代码模板
```jsx
{/* Block 1: topbar */}
<div className={styles.topbarTitle}>标题</div>
{/* Block 2: card */}
<div className={styles.cardTitle}>卡片标题</div>
```
```scss
.topbarTitle { font-size: 32px; color: #003366; }
.cardTitle { font-size: 24px; color: #666; }
```
## 违反后果
- **产物表现**: 类名冲突,后 block 的样式盖住前 block,视觉错乱
- **典型事故**:
- v0.3.16 test8 事故 — 3 个 block 都用 `.title`,合并后只剩 footer 样式
## Rule-Scan 识别提示
- Read `pp-d2c.config.json`,若 `merge.mode !== 'flat'` → 跳过 R12 判定
- 遍历产物 SCSS,检索**同名 selector**是否出现多次
- 或提前扫每个 block 的类名列表,交叉比对
## 相关
- SKILL.md §5 合并策略
- pp-d2c.config.json `merge.mode`
# R13 - unit-scale
## 判定归属
- **硬防线** (check-rules.mjs 自动拦截): ❌ (未来可升,现阶段需 config 解析)
- **软防线** (Rule-Scan sub-agent 识别): ✅ (**唯一识别方**)
- **排斥条件**: `pp-d2c.config.json` 的 `unit.figmaBase === unit.outputBase` → scale=1,不适用
## 触发条件
- **config**: `unit.scale !== 1`(常见 Figma 375 base 输出到 750 px,scale=2)
- **命中信号**: 产物 CSS 中的 `px` 数字与 Figma 原始尺寸相同(意味着没换算)
## 期望产物
**换算公式**:
```
outputPx = figmaPx * (unit.outputBase / unit.figmaBase)
```
**举例**(figmaBase=375, outputBase=750, scale=2):
| Figma 尺寸 | 输出尺寸 |
|---|---|
| `x=20, y=44` | `20 * 2 = 40px, 44 * 2 = 88px` |
| `width=335, height=118` | `670px × 236px` |
| `fontSize=16` | `32px` |
| `borderRadius=8` | `16px` |
| `padding: 12 20` | `24px 40px` |
**SCSS 端**:
- 所有 `px` 值都是换算后的输出值,不含 Figma 原值
## 反例 (agent 常见错法)
```scss
/* Figma cache: node.width = 335, node.absoluteBoundingBox.height = 118
config: figmaBase=375, outputBase=750, scale=2
期望输出: width=670px, height=236px
agent 错法: */
.card {
width: 335px; /* ❌ Figma 原值,未换算 */
height: 118px; /* ❌ */
padding: 12px; /* ❌ */
font-size: 16px; /* ❌ */
border-radius: 8px; /* ❌ */
}
/* 期望 */
.card {
width: 670px;
height: 236px;
padding: 24px;
font-size: 32px;
border-radius: 16px;
}
```
## 落地代码模板
**UI sub-agent 生 SCSS 前应用换算**:
```js
const config = readConfig();
const scale = config.unit.outputBase / config.unit.figmaBase;
function toPx(figmaPx) {
return `${Math.round(figmaPx * scale)}px`;
}
// 使用
.card {
width: ${toPx(node.width)};
height: ${toPx(node.height)};
}
```
## 违反后果
- **产物表现**:
- 375 base 输出到 750 屏幕 → 元素显示只有一半大小
- 或应用 `rem` 换算时,基础字号错乱
- **典型事故**:
- v0.3.8 test1 事故 — agent 忘换算,整个页面在真机上缩小一半
## Rule-Scan 识别提示
- Read `pp-d2c.config.json`,取 `unit.figmaBase` / `unit.outputBase`
- scale = outputBase / figmaBase
- 若 scale === 1 → 不适用
- 命中判定:
- Read 产物 SCSS,提取所有 `\d+px`
- 反查每个 px 值对应的 nodeId,读 cache 的 `absoluteBoundingBox.width/height` / `x/y`
- 若产物 px === Figma px 且 scale != 1 → 命中 R13
## 相关
- SKILL.md §4.5 单位换算
- pp-d2c.config.json `unit.figmaBase` / `unit.outputBase`
- rules/R10-no-fake-solid-color.md
# R14 - fixed-z-index
## 判定归属
- **硬防线** (check-rules.mjs 自动拦截): ❌
- **软防线** (Rule-Scan sub-agent 识别): ✅ (**唯一识别方**)
- **排斥条件**: 页面只有 1 个 `fixed-` 节点 → 无冲突,不判 z-index
## 触发条件
- **cache**: 存在**多个** `node.name.startsWith('fixed-')` 节点(通常是状态栏 + 底部 bar + 悬浮按钮)
- **命中信号**: 多层 fixed 节点在 Figma 里有明确 z 顺序(通常按图层顺序),产物 z-index 缺失或未递增
## 期望产物
**核心原则**: 多个 fixed 元素必须有递增 z-index,视觉层级依 Figma 图层顺序。
**SCSS 端**:
```scss
.fixedStatusBar { position: fixed; top: 0; z-index: 100; }
.fixedTopbar { position: fixed; top: 118px; z-index: 90; }
.fixedFloatingBtn { position: fixed; bottom: 40px; right: 40px; z-index: 200; }
```
**约定**:
- 底部 bar / 悬浮按钮 → z-index ≥ 100
- 顶部固定 bar → z-index 递减(status > topbar > sub-nav)
- **不要**都用 z-index: 1 或都不写
## 反例 (agent 常见错法)
```scss
/* Figma 有 3 个 fixed 节点:
- fixed-状态栏
- fixed-顶部bar
- fixed-悬浮按钮
期望层级: 状态栏 > 悬浮按钮 > 顶部bar
agent 错法 1: 全部不写 z-index */
.fixedStatusBar { position: fixed; top: 0; }
.fixedTopbar { position: fixed; top: 118px; }
.fixedFloatingBtn { position: fixed; bottom: 40px; }
/* → z-index 默认为 auto,层级不确定,可能互相覆盖 */
/* agent 错法 2: 全部 z-index 相同 */
.fixedStatusBar { z-index: 1; }
.fixedTopbar { z-index: 1; }
.fixedFloatingBtn { z-index: 1; }
```
## 落地代码模板
```scss
.fixedStatusBar {
position: fixed;
top: 0;
z-index: 100;
}
.fixedTopbar {
position: fixed;
top: 118px;
z-index: 90;
}
.fixedFloatingBtn {
position: fixed;
bottom: 40px;
right: 40px;
z-index: 200;
}
```
## 违反后果
- **产物表现**: fixed 元素互相覆盖、位置错乱
- **典型事故**:
- v0.3.19 test8 事故 — 底部 fixed 悬浮按钮被上滑的内容盖住(z-index 未设)
## Rule-Scan 识别提示
- 遍历 cache 收集 `fixed-` 前缀节点
- ≥2 → 判定 R14
- Figma 图层顺序(`children` 数组索引)对应视觉 z:索引大的在上
- 输出 context 里列出各 fixed 节点及推荐 z-index 值
## 相关
- rules/R01-fixed-position.md
- SKILL.md §4.3 硬规则第 1 条 (fixed-)
# R15 - 同构 map 渲染
## 判定归属
- **硬防线** (check-rules.mjs 自动拦截): ❌
- **软防线** (Rule-Scan sub-agent 识别): ✅ (**唯一识别方**)
- **排斥条件**:
- 同层 <3 个同构节点 → 不强制 map
- 每个节点有明显不同的交互 / 命名 / 结构差异 → 保留独立元素
## 触发条件
- **cache**: 同一父节点下有 **≥3 个** 结构同构的子节点
- **同构判定**:
- 相同 `type`
- 相同 `children` 结构(层级 + 类型分布相同)
- 相同 `name` 前缀(如 `item-1`, `item-2`, `item-3` 或 `card_1`, `card_2`)
- **命中信号**: agent 展开成 3+ 份重复 JSX + SCSS
## 期望产物
**JSX 端** (强制):
```jsx
{[
{ title: '...', desc: '...' },
{ title: '...', desc: '...' },
{ title: '...', desc: '...' },
].map((item, i) => (
<div key={i} className={styles.card}>
<div className={styles.cardTitle}>{item.title}</div>
<div className={styles.cardDesc}>{item.desc}</div>
</div>
))}
```
**SCSS 端** (强制):
- 同构节点**只写一份** `.card { ... }` 规则
- 通过 `:nth-child(n)` 处理个别差异(通常无差异,不需要)
## 反例 (agent 常见错法)
```jsx
{/* Figma: 同层 3 个 item-* 节点 */}
{/* ❌ 错法: 展开 3 份重复 JSX */}
<div className={styles.item1}>
<div className={styles.item1Title}>标题 1</div>
<div className={styles.item1Desc}>描述 1</div>
</div>
<div className={styles.item2}>
<div className={styles.item2Title}>标题 2</div>
<div className={styles.item2Desc}>描述 2</div>
</div>
<div className={styles.item3}>
<div className={styles.item3Title}>标题 3</div>
<div className={styles.item3Desc}>描述 3</div>
</div>
```
```scss
/* ❌ 错法: 展开 3 份重复 SCSS */
.item1 { ... }
.item1Title { ... }
.item2 { ... }
.item2Title { ... }
.item3 { ... }
.item3Title { ... }
```
## 落地代码模板
```jsx
const CARDS = [
{ title: '预约票', desc: '开售自动抢' },
{ title: '优惠券', desc: '限时领取' },
{ title: '会员权益', desc: '专享特惠' },
];
<div className={styles.cardList}>
{CARDS.map((card, i) => (
<div key={i} className={styles.card}>
<div className={styles.cardTitle}>{card.title}</div>
<div className={styles.cardDesc}>{card.desc}</div>
</div>
))}
</div>
```
```scss
.cardList {
display: flex;
flex-direction: column;
gap: 20px;
}
.card {
padding: 30px;
background: #FFFFFF;
border-radius: 16px;
}
.cardTitle { font-size: 32px; font-weight: bold; }
.cardDesc { font-size: 24px; color: #666; }
```
## 违反后果
- **产物表现**:
- 代码冗长 3 倍以上,可维护性差
- 后续加/删一项需要多处改
- SCSS 类名膨胀,增大产物体积
- **典型事故**:
- v0.3.14 test8 事故 — 6 个卡片各 15 行 JSX + 30 行 SCSS,合计 270 行,合并成 map 后仅 40 行
## Rule-Scan 识别提示
- 找同层 ≥3 个子节点
- 判断"同构":
1. `type` 相同
2. 子结构签名相同(如 `[TEXT, TEXT, VECTOR]`)
3. 名字前缀相同(可选)
- 输出 context 里列出:
- 同构节点 nodeId 列表
- 每个节点的可提取内容差异(如 title / imageUrl)
## 相关
- rules/R12-flat-mode-naming.md
# R16 - no-flatten-text
## 判定归属
- **硬防线** (check-rules.mjs 自动拦截): ✅
- **软防线** (Rule-Scan sub-agent 识别): ✅ (兜底,建议 sub-agent 生成前也自查一遍)
- **排斥条件**:
- 节点 name **裸词** `img` / `bg` → 免疫
- 节点 name 以 **`img-`** / **`bg-`** 开头(且后有字符)→ 免疫
- 节点 `type` 不在 `{GROUP, FRAME, COMPONENT, INSTANCE}` → 不适用
## 触发条件
**同时满足**:
1. `node.type` ∈ `{GROUP, FRAME, COMPONENT, INSTANCE}`
2. `node.name` 前缀 **非** `img-` / `bg-` / 裸词 `img` / `bg`
3. 该节点子树(递归 children)中 **存在** 至少一个 `TEXT` 类型节点
4. 产物 jsx 中出现 `<img ... data-node-id="<该节点 nodeId>" ... />`(无论跨行/属性顺序)
命中 → 违规。
## 期望产物
**JSX 端**:
- 不允许 `<img data-node-id="<该 nodeId>">`
- 应按 §4.3 前缀规则递归拆解子树:
- TEXT 子节点 → `<span>` / `<p>` 展开
- `btn-` 子节点 → `<button>` + CSS 化
- `img-` 子节点 → `<img>` 引用切图
- `bg-` 子节点 → 挂父容器 `background-image`
- 无前缀装饰性 vector 子树 → 走 R03 隐式切图
**assets.txt 端**:
- 不允许出现该 nodeId 的"整体切图"行
## 反例(agent 常见错法)
```jsx
{/* ❌ Frame 745 含 TEXT "北京时间18:00开抢" + btn,被整体烤成 png */}
<img
className={styles.couponBig}
src={`${ASSET}frame-745.png`}
data-node-id="211:171"
alt=""
/>
```
对应 cache:
```json
{
"id": "211:171",
"name": "Frame 745",
"type": "FRAME",
"children": [
{ "id": "211:174", "name": "coupon-big-bg", "type": "RECTANGLE" },
{ "id": "211:198", "name": "折扣数字", "type": "TEXT" }, // ← 触发 R16
{ "id": "211:212", "name": "btn-q", "type": "GROUP" }
]
}
```
## 落地代码模板
```jsx
{/* ✅ 拆解子树;bg-* 挂父 background;TEXT 用 <span>;btn-* 用 <button> */}
<div className={styles.couponBig} data-node-id="211:171">
{/* 211:174 bg-* → 父容器 background-image,此处不生成 DOM */}
<span className={styles.couponBigNum} data-node-id="211:198">1折</span>
<button className={styles.couponBigBtn} type="button" data-node-id="211:212">
立即抢
</button>
</div>
```
```scss
.couponBig {
position: relative;
width: 718px;
height: 300px;
background: url('#{$asset-prefix}coupon-big-bg.png') no-repeat center / 100% 100%;
}
```
## 违反后果
- **产物表现**:TEXT 无障碍缺失(屏幕阅读器读不到)、无法本地化(换语言换不了)、按钮不可点击、体验不可修改
- **典型事故**:v1.0.0 test12 事故 — Frame 745/744/762/img-title-quan + sub-MAIN 五处被整体烤成 png,91 条 R02/R06 违规被 agent 用 `[整体切图兜底]` 标签自签豁免
## 与其他规则的关系
- **R03 implicit-image**:R03 自身已排斥子树含 TEXT 的情况;R16 是"哪怕 agent 无视 R03 的排斥条件,最终也拦得住"的兜底
- **R02 fills-image**:R02 只管 fills 含 IMAGE 的节点必须落切图;R16 管 fills 不含 IMAGE 但被违规整体切图的情况
- **§6.0.2 兜底防线 N=0**:R16 输出的 violation 一律不许被 `[整体切图兜底]` 豁免(该标签已废除)
## Rule-Scan 识别提示
- 遍历 cache 里 `type ∈ {GROUP, FRAME, COMPONENT, INSTANCE}` 且 `name` 非白名单的节点
- 递归 `children` 判断子树是否含 `TEXT`
- 是 → 在 `rule-hits.json` 里给该节点标记 `rule: 'R16'` + `expected: '禁止整体切图;按前缀拆解子树'`
- UI sub-agent 生成 jsx 时若准备对该节点写 `<img>` → 强制回退到子树递归
## 相关
- SKILL.md §4.3 图层解析规则(前缀白名单来源)
- SKILL.md §6.0.2 兜底防线(配合本规则彻底废除"整体切图兜底"路径)
- rules/R02-fills-image.md
- rules/R03-implicit-image.md
- rules/R06-text-solid-last.md
# R17 - no-baked-dom(v1.2.0 对账新增)
## 判定归属
- **硬防线** (check-rules.mjs 自动拦截): ✅
- **软防线** (Rule-Scan sub-agent 识别): ✅(兜底)
- **排斥条件**:
- 节点 `_inBakedSubtree !== true`(不在整体切图子树内)→ 不适用
- 节点 `_templateDup === true`(`.map()` 数据副本)→ 跳过(代表项报过即可)
## 触发条件
**同时满足**:
1. 节点处于 `bg-` / `img-`(整体切图,含裸词 `bg`/`img`)或 `x-`(整体忽略)前缀节点的**子树内**(`loadCache.mjs` 标注 `_inBakedSubtree === true`,`_bakedBy` 指向那个前缀节点)。**不含 `bgc-`**:bgc- 是盒级 CSS 写父、非切图,其子孙走正常规则(见 R21)
2. 产物 JSX 中出现该节点的 `data-node-id="<nodeId>"` 元素(跨行 / 属性顺序任意)
命中 → 违规(双重渲染)。
## 期望产物
- 整体切图子树内的节点,其像素**已经在父层切图 PNG 里**(`bg-`/`bgc-`/`img-`),或被 `x-` 整体忽略
- 产物中**不得**再有其 `data-node-id` 元素——它不该作为独立 DOM 出现
- 与 R02/R06 分工:R02/R06 **跳过** baked 子孙(不逐个溯源),"禁 DOM" 由本条正向兜底
## 反例(agent 常见错法)
```jsx
{/* ❌ bg-main(211:39) 整体切成 main.png,title/subtitle 文字像素已在 PNG 里 */}
<div className="page__main-bg" data-node-id="211:39" />
{/* ↓ 但又把 bg-main 子孙的 title-text/subtitle 生成成 DOM → 文字渲染两遍、叠字 */}
<div className="page__title-row" data-node-id="211:410">
<span className="page__title-text" data-node-id="211:83">中秋火车票开售预测</span>
</div>
<p className="page__subtitle" data-node-id="211:84">官方尚未给出明确开售时间...</p>
```
对应 cache(211:83 / 211:84 是 bg-main 211:39 的子孙):
```json
{ "id": "211:39", "name": "bg-main", "type": "GROUP",
"children": [ { "id": "211:410", "children": [ { "id": "211:83", "type": "TEXT" } ] },
{ "id": "211:82", "children": [ { "id": "211:84", "type": "TEXT" } ] } ] }
```
## 落地代码模板
```jsx
{/* ✅ bg-main 整体切图 → 只保留背景层,子孙文字不再出 DOM(已在 main.png 里) */}
<div className="page__main-bg" data-node-id="211:39" />
{/* title-text / subtitle 不生成 —— 它们的像素在 main.png 中 */}
```
若文案需要动态替换 → 见 §4.3「含 TEXT 容器 压平 vs 拆」裁决树:改走**拆结构**(去掉 bg- 前缀,文字出 DOM,背景单独切成不含文字的图),而非"既烤又留"。
## 违反后果
- **产物表现**:文字/图叠一遍(切图里一份 + DOM 一份),视觉重影、错位
- **典型事故**:v1.1.0 test13 — bg-main 的 title-text/subtitle/韩国贴纸既进 main.png 又出 DOM,用户手工删除 DOM 才修正
## 与其他规则的关系
- **R16 no-flatten-text**:R16 管"不该压平的容器被整体切图";R17 管"该压平的容器压平后子孙又出 DOM"。二者是同一矛盾(压平 vs 拆)的两面
- **R02/R06**:跳过 `_inBakedSubtree` 节点(不误报"缺 url/缺 color"),"禁 DOM" 交由 R17
- **§6.0.2 兜底防线 N=0**:R17 违规一律不许豁免,回滚
## Rule-Scan 识别提示
- 遍历 cache,找 `_inBakedSubtree === true` 的节点
- 检查产物 JSX 是否有其 `data-node-id`
- 有 → 标记 `rule: 'R17'` + `expected: '整体切图子树内节点禁止出 DOM(像素已在父层切图)'`
## 相关
- SKILL.md §4.3「含 TEXT 容器 压平 vs 拆」裁决树
- SKILL.md §6.0.2 兜底防线
- rules/R16-no-flatten-text.md
- bin/lib/loadCache.mjs(`_inBakedSubtree` 标注)
# R18 - flex-direction(v1.2.0 对账新增)
## 判定归属
- **硬防线** (check-rules.mjs 自动拦截): ✅
- **软防线** (Rule-Scan sub-agent 识别): ✅(兜底)
- **排斥条件**:
- `layoutMode` 非 `HORIZONTAL`/`VERTICAL`(非 autolayout 容器)→ 不适用
- `_inBakedSubtree` / `_hidden` / `_templateDup` → 跳过
- `classMap[nodeId]` 为空(不可追溯)→ 不报,由 §5.1.1 data-node-id 铁律在生成侧兜底
- 该节点 CSS 规则体无 `display: flex` → 不判定(可能非 flex 实现)
## 触发条件
**同时满足**:
1. `node.layoutMode` ∈ `{HORIZONTAL, VERTICAL}`
2. 节点有 `data-node-id` + className 映射,且 CSS 规则体含 `display: flex`
3. CSS 的 `flex-direction` 与 Figma `layoutMode` **不符**:
- `layoutMode === 'VERTICAL'` 但 CSS **非** `flex-direction: column`(含缺省,默认 row)→ 违规
- `layoutMode === 'HORIZONTAL'` 但 CSS **是** `flex-direction: column`(方向写反)→ 违规
命中 → 违规。
## 期望产物
| Figma `layoutMode` | CSS `flex-direction` |
|--------------------|----------------------|
| `VERTICAL` | `column`(**必须显式写**,否则默认 row 会横排) |
| `HORIZONTAL` | `row` 或省略(row 是 flex 默认,可不写) |
## 反例(agent 常见错法)
```scss
/* ❌ small-card-top 对应 Figma Frame 764 layoutMode=VERTICAL,却写成 row */
.page {
&__small-card-top {
display: flex;
flex-direction: row; /* ← 应为 column,价格与描述该竖排却横排 */
align-items: center;
}
}
```
对应 cache:
```json
{ "id": "211:221", "name": "Frame 764", "type": "FRAME", "layoutMode": "VERTICAL" }
```
## 落地代码模板
```scss
/* ✅ VERTICAL → 显式 column */
.page {
&__small-card-top {
display: flex;
flex-direction: column;
align-items: center;
}
}
```
## 违反后果
- **产物表现**:子元素排列方向反了(竖排变横排 / 横排变竖排),整块布局错乱
- **典型事故**:v1.1.0 test13 — small-card-top(Figma VERTICAL)写成 `flex-direction: row`,价格与描述横排;且该节点在 `.map()` 模板里没挂 data-node-id,逃过校验直接上线
## 与其他规则的关系
- **§5.1.1 data-node-id 全覆盖铁律**:R18 靠 data-node-id 绑定;`.map()` 模板必须挂代表项(variant a)id,否则 R18 无法校验(正是 test13 逃逸根因)
- **lib/cssMatch.mjs**:R18 用它匹配 SCSS `&__foo` 嵌套写法,避免"产物嵌套、正则找平铺"的假阴性
## Rule-Scan 识别提示
- 遍历 cache 里 `layoutMode ∈ {HORIZONTAL, VERTICAL}` 的容器
- 提示 UI sub-agent:VERTICAL 必写 `flex-direction: column`;勿凭视觉猜方向
- 标记 `rule: 'R18'` + `expected: 'VERTICAL→column / HORIZONTAL→row'`
## 相关
- SKILL.md §4.3 判定优先级(子视角 layoutMode↔flex-direction)
- SKILL.md §5.1.1 data-node-id 全覆盖铁律
- bin/lib/cssMatch.mjs
- rules/R19-padding.md(同为 autolayout 容器忠实度)
# R19 - padding(v1.2.0 对账新增)
## 判定归属
- **硬防线** (check-rules.mjs 自动拦截): ✅
- **软防线** (Rule-Scan sub-agent 识别): ✅(兜底)
- **排斥条件**:
- `layoutMode` 非 `HORIZONTAL`/`VERTICAL`(padding 仅在 autolayout 容器有意义)→ 不适用
- `_inBakedSubtree` / `_hidden` / `_templateDup` → 跳过
- `classMap[nodeId]` 为空 → 不报,由 §5.1.1 兜底
- CSS padding 含非 px 值(%/auto/var/calc)→ 放弃比对(不误报)
## 触发条件
设 Figma 期望 padding = `[paddingTop, paddingRight, paddingBottom, paddingLeft] × scale`,容差 2px。
**任一命中**:
1. Figma 四边 padding 均为 0,但产物写了非 0 padding → **凭空捏造**
2. Figma 有 padding,但产物**未写** padding → 漏写
3. 产物 padding 某边与 Figma×scale **相差 > 2px** → 数值错
命中 → 违规。
## 期望产物
- CSS padding 四值 ≈ `Figma paddingT/R/B/L × scale`
- Figma pad0 → 产物**不写** padding(或显式 0)
- 支持 shorthand(`padding: 0 82px 10px 82px`)与 longhand(`padding-left` 等),longhand 覆盖 shorthand 对应边
- **无单位 0 合法**(`padding: 0 12px` 里的 `0` 按 0px 处理)
## 反例(agent 常见错法)
```scss
/* ❌ small-card-top 对应 Figma Frame 764 四边 padding 全 0,却凭空加了 0 12px */
.page {
&__small-card-top {
display: flex;
padding: 0 12px; /* ← Figma pad0,凭空捏造 */
}
}
```
对应 cache:
```json
{ "id": "211:221", "layoutMode": "VERTICAL",
"paddingTop": 0, "paddingRight": 0, "paddingBottom": 0, "paddingLeft": 0 }
```
## 落地代码模板
```scss
/* ✅ Figma pad0 → 不写 padding */
.page {
&__small-card-top { display: flex; flex-direction: column; }
}
/* ✅ Figma pad=[0,41,5,41],scale=2 → padding: 0 82px 10px 82px */
.page {
&__btn-q { display: flex; padding: 0 82px 10px 82px; }
}
```
## 违反后果
- **产物表现**:内容框内边距多/少,元素错位、挤压或留白异常
- **典型事故**:v1.1.0 test13 — small-card-top(Figma pad0)凭空加 `padding: 0 12px`;btn-q(Figma pad=[0,41,5,41])漏写 padding
## 与其他规则的关系
- **R18 flex-direction**:同为 autolayout 容器忠实度,成对使用
- **R13 unit-scale**:R19 的期望值走 `Figma padding × scale`,与 R13 换算口径一致
- **§5.1.1**:靠 data-node-id 绑定,模板项挂代表项 id
## Rule-Scan 识别提示
- 遍历 cache 里 autolayout 容器,读 `paddingTop/Right/Bottom/Left`
- 提示 UI sub-agent:padding 从 Figma 读,勿凭视觉估;Figma 0 就别写
- 标记 `rule: 'R19'` + `expected: 'padding = Figma四边×scale'`
## 相关
- SKILL.md §4.3 判定优先级(父视角 padding)
- SKILL.md §5.1.1 data-node-id 全覆盖铁律
- bin/lib/cssMatch.mjs
- rules/R18-flex-direction.md
# R20 - absolute-position(v1.2.0 对账新增)
## 判定归属
- **硬防线** (check-rules.mjs 自动拦截): ✅
- **软防线** (Rule-Scan sub-agent 识别): ✅(兜底)
- **排斥条件**:
- `layoutPositioning !== 'ABSOLUTE'`(未脱离父顺流)→ 不适用
- `name` 以 `fixed-` 开头 → 跳过(走 constraints 视口定位,R01 域,非 bbox 相对定位)
- `_inBakedSubtree` / `_hidden` / `_templateDup` → 跳过
- `classMap[nodeId]` 为空 → 不报,由 §5.1.1 兜底
- 节点或父节点缺 `absoluteBoundingBox` → 无法精确计算,不误报
## 触发条件
设 `scale` = `config.unit.scale`(默认 2),容差 4px,父 = `cache.nodes[node._parentId]`:
- 期望 `left = (node.bbox.x − parent.bbox.x) × scale`
- 期望 `top = (node.bbox.y − parent.bbox.y) × scale`
**任一命中**:
1. 产物写了 `top`/`left` 但与期望**相差 > 4px** → 坐标错(典型:靠猜)
2. 期望值**非 0**(|exp| > 4)但产物**缺** `top`/`left` → 丢了真实偏移
命中 → 违规。
> **容忍**:期望值 ≈ 0 且产物未显式声明 → 原点绝对定位与顺流视觉等价,不报(避免噪声)。支持 `top: 0` 无单位零与 `inset` 简写。
## 期望产物
- ABSOLUTE 子节点:`position: absolute` + `top`/`left` = `(子bbox − 父bbox) × scale`
- 父容器加 `position: relative`
- **能从 bbox 精确算出的坐标,禁止靠猜 + 「需人工核对」兜底**(§6.0.2 已封该逃逸口)
## 反例(agent 常见错法)
```scss
/* ❌ img-huochepiao 相对父 Frame 760:真值 left=-5/top=-6.5(×2=-10/-13,溢出到背景上)
产物却写正值 40/40,方向完全反 */
.page {
&__screen-piao {
position: absolute;
top: 40px; /* ← 应 -13px */
left: 40px; /* ← 应 -10px */
}
}
```
对应 cache:
```json
{ "id": "211:440", "name": "img-huochepiao", "layoutPositioning": "ABSOLUTE",
"absoluteBoundingBox": { "x": -4016, "y": 248 },
"_parentId": "211:102" } // 父 Frame 760 bbox.x=-4011, y=254.5
```
## 落地代码模板
```scss
/* ✅ left=(-4016−(-4011))×2=-10;top=(248−254.5)×2=-13 */
.page {
&__screen { position: relative; } /* 父加 relative */
&__screen-piao {
position: absolute;
top: -13px;
left: -10px;
}
}
```
## 违反后果
- **产物表现**:绝对定位元素位置错乱(本该溢出到背景上的贴纸缩进容器内 / 偏移丢失贴边错位)
- **典型事故**:v1.1.0 test13 — img-huochepiao 坐标靠猜写 40/40(应 -10/-13),agent 用「需人工核对」兜底交付
## 与其他规则的关系
- **R01 fixed-position**:`fixed-` 前缀走 constraints 视口定位,由 R01 管;R20 只管非 fixed 的 `layoutPositioning: ABSOLUTE`
- **§6.0.2**:R20 覆盖的坐标是"可机械计算量",禁用「需人工核对」豁免
- **§5.1.1**:靠 data-node-id 绑定
## Rule-Scan 识别提示
- 遍历 cache 里 `layoutPositioning === 'ABSOLUTE'` 且非 `fixed-` 的节点
- 提示 UI sub-agent:top/left 用 `(子bbox − 父bbox) × scale` 算,勿目测;父加 `position: relative`
- 标记 `rule: 'R20'` + `expected: 'top/left=(子bbox−父bbox)×scale'`
## 相关
- SKILL.md §4.3 判定优先级第 0 条(`layoutPositioning: ABSOLUTE`)
- SKILL.md §6.0.2(禁「需人工核对」用于可计算量)
- rules/R01-fixed-position.md
- bin/lib/loadCache.mjs(`_parentId` 供父 bbox 查询)
# R21 - node-id-coverage(v1.2.1 对账新增)
## 判定归属
- **硬防线** (check-rules.mjs 自动拦截): ✅(优先级最高——节点无 node-id 则其余绑定类规则全失效)
- **软防线** (Rule-Scan sub-agent 识别): ✅(兜底)
- **排斥条件**:
- `_inBakedSubtree`(bg-/img- 整体切图 或 x- 忽略子树,本就不出独立 DOM)→ 跳过
- `_hidden`(不渲染)→ 跳过
- `_templateDup`(`.map()` 数据副本,只需代表项挂 id)→ 跳过
- `name` 前缀 `bg-` / `bgc-` / `x-`(自身不生成独立 DOM:bg/bgc 挂父,x 忽略)→ 跳过
## 触发条件
**同时满足**:
1. 节点"应生成独立 DOM"(满足任一):
- `type === 'TEXT'`
- autolayout 容器:`layoutMode ∈ {HORIZONTAL, VERTICAL}`
- `layoutPositioning === 'ABSOLUTE'`(需 R20 校验坐标)
- `name` 前缀 `img-` / `btn-` / `input-`(生成 `<img>`/`<button>`/`<input>`)
2. 产物 JSX 中**找不到**该节点的 `data-node-id="<nodeId>"`(跨行/属性顺序任意)
命中 → 违规(不可追溯)。
## 期望产物
- 凡承载 Figma 语义、会渲染成 DOM 的节点,产物元素必须带 `data-node-id="<nodeId>"`
- `.map()` 模板项:用**代表项(列表第一个同构兄弟 = variant a)**的 nodeId 挂在模板元素上;副本(variant b/c)已被 `_templateDup` 跳过,只校验代表项
- 详见 SKILL.md §5.1.1 data-node-id 全覆盖铁律
## 为什么必须机械强制
没有 node-id,R06(字色)/R18(flex 方向)/R19(padding)/R20(绝对坐标)在 `classMap[nodeId]` 为空时只能 `continue` → **对账规则集体失灵**。若只把"挂 id"写成文档铁律,agent 仍可漏挂 → bug 静默逃逸(典型 test13:`.small-card-top` 无 data-node-id,flex 方向反 + 幻觉 padding 直接上线,R18/R19 绑定不上没拦住)。R21 把"应渲染却无 id"本身变成硬违规,从机制上堵死这条逃逸。
## 反例(agent 常见错法)
```jsx
{/* ❌ .map() 模板:容器与文字都没挂 data-node-id → 逃出 R18/R19/R06 校验 */}
{CARDS.map((c) => (
<div className="page__small-card" key={c.key}>
<div className="page__small-card-top"> {/* 无 data-node-id */}
<span className="page__small-card-title">{c.title}</span> {/* 无 data-node-id */}
</div>
</div>
))}
```
## 落地代码模板
```jsx
{/* ✅ 模板挂代表项(variant a)的 nodeId;副本由 _templateDup 跳过 */}
{CARDS.map((c) => (
<div className="page__small-card" data-node-id="211:218" key={c.key}>
<div className="page__small-card-top" data-node-id="211:221">
<span className="page__small-card-title" data-node-id="211:227">{c.title}</span>
</div>
</div>
))}
```
## 违反后果
- **产物表现**:节点不可追溯 → 对账规则绑定不上 → 布局/字色/坐标错误无人拦截,静默上线
- **典型事故**:v1.1.0 test13 — `.map()` 模板的 small-card-top / 文字节点全无 data-node-id,R18/R19 无法校验,flex 方向反 + 幻觉 padding 逃逸
## 与其他规则的关系
- **§5.1.1 data-node-id 全覆盖铁律**:R21 是该铁律的机械执行体
- **R06**:R06 只校验"可追溯 TEXT 的字色";"TEXT 不可追溯"由 R21 统一报,二者不双报同一节点
- **R18/R19/R20**:都靠 data-node-id 绑定;R21 保证绑定前提成立
- **_templateDup**:R21 只校验代表项,副本跳过——与"模板挂代表项 id"策略配套
## Rule-Scan 识别提示
- 遍历 cache 里"应渲染"节点(TEXT / autolayout 容器 / ABSOLUTE / img-·btn-·input-)
- 排除 baked/hidden/templateDup 与 bg-/bgc-/x- 前缀
- 提示 UI sub-agent:每个这类节点必挂 data-node-id,模板挂代表项 id
- 标记 `rule: 'R21'` + `expected: '应渲染节点必挂 data-node-id'`
## 相关
- SKILL.md §5.1.1 data-node-id 全覆盖铁律
- bin/lib/loadCache.mjs(`_inBakedSubtree`/`_hidden`/`_templateDup` 标注)
- rules/R18-flex-direction.md / R19-padding.md / R20-absolute-position.md(依赖 R21 保证的可追溯前提)
# pp-d2c 规则库
> pp-d2c skill 硬性规则的原始定义。当 rules/*.md 内容与 SKILL.md 冲突时以 rules/ 为准。
## 内置前缀常量表(硬编码,不可配置)
Figma 图层名前缀是**内置常量**,写死在 skill 里,不再从 config 读取。所有规则(SKILL / rules/ / check-rules.mjs / Rule-Scan sub-agent / UI sub-agent)一律用下表值:
| 前缀 | 语义 |
|---|---|
| `sub-` | 分块边界(sub-agent 派发单元) |
| `block-` | 独立布局块(命名空间隔离) |
| `img-` | 图片内容(生成 `<img>`,不递归) |
| `bg-` | 背景图(挂父容器 background-image,自身不生成 DOM) |
| `bgc-` | 背景纯色(全套盒级 CSS 写父,自身不生成 DOM) |
| `btn-` | 可点击区域(永远 CSS 化) |
| `scrollx-` | 横向滚动容器 |
| `scrolly-` | 纵向滚动容器 |
| `fixed-` | 视口固定定位(修饰前缀) |
| `end-` | 逆向布局(贴父末端,修饰前缀) |
| `input-` | 输入框(生成 `<input type="text">`,不递归) |
| `x-` | 忽略(跳过整层,优先级最高) |
**pp-d2c.config.json 里不再有 `layers` 段**——之前的 `layers.sub` / `layers.bg` / `layers.but` 等映射都已删除。
## 索引表
| ID | 名称 | 判定归属 | 一句话触发条件 |
|---|---|---|---|
| R01 | fixed-position | 硬防线 | `name.startsWith('fixed-')` |
| R02 | fills-image | 硬防线 | `fills[].some(f => f.type === 'IMAGE' && f.visible !== false)` |
| R03 | implicit-image | 软防线 | 无前缀 + 整棵子树 VECTOR/BOOL/几何 + 无 TEXT/INSTANCE + 无 btn-/input-/sub-/block- 子层 |
| R04 | text-gradient | 软防线 | `TEXT` 节点,fills 末位可见 = `GRADIENT_*` 或 `IMAGE` |
| R05 | space-between | 硬防线 | `primaryAxisAlignItems === 'SPACE_BETWEEN'` |
| R06 | text-solid-last | 硬防线 | `TEXT` 节点,fills 末位可见 = `SOLID` |
| R07 | multi-fills | 软防线 | fills 数组多层可见(≥2),且不全是 SOLID |
| R08 | bg-landing-form | 硬防线 | `name.startsWith('bg-')` 或 `name === 'bg'`,产物落地形态错 |
| R09 | btn-bgc-取值 | 软防线 | `btn-` 前缀内含 `bgc-` 子层,bgc 的真 fills 是 GRADIENT/IMAGE |
| R10 | no-fake-solid-color | 软防线 | 产物 CSS 出现 `color: #XXX`,但 cache 里对应节点找不到源头 |
| R11 | mask-vector-css-able | 软防线 | 复合 mask / 多层 vector,CSS 表达不了 → 应切图 |
| R12 | flat-mode-naming | 软防线 | `merge.mode === 'flat'` 下类名跨 block 冲突 |
| R13 | unit-scale | 软防线 | Figma px → 产物 px 未换算(应 `outputBase / figmaBase`) |
| R14 | fixed-z-index | 软防线 | 多个 `fixed-` 节点,z-index 未递增 |
| R15 | 同构 map 渲染 | 软防线 | 同层 ≥3 同构子节点,展开成重复代码而非 `.map()` |
| R16 | no-flatten-text | 硬防线 | GROUP/FRAME/COMPONENT/INSTANCE 子树含 TEXT 且前缀非 `img-`/`bg-`,产物 jsx 出现 `<img data-node-id="该节点">` |
| R17 | no-baked-dom | 硬防线 | 节点 `_inBakedSubtree`(处于 bg-/bgc-/img-/x- 整体切图子树内),产物却有其 `data-node-id`(双重渲染) |
| R18 | flex-direction | 硬防线 | autolayout 容器 `layoutMode` 与产物 `flex-direction` 不符(VERTICAL 却非 column / HORIZONTAL 却 column) |
| R19 | padding | 硬防线 | autolayout 容器 padding 与 `Figma paddingT/R/B/L × scale` 不符(凭空加 / 漏写 / 数值错) |
| R20 | absolute-position | 硬防线 | `layoutPositioning === 'ABSOLUTE'`(非 fixed-),top/left ≠ (子bbox−父bbox)×scale |
| R21 | node-id-coverage | 硬防线 | 应渲染节点(TEXT/autolayout 容器/ABSOLUTE/img-·btn-·input-)在产物 JSX 里找不到 data-node-id |
## 判定归属说明
**硬防线** (`check-rules.mjs` 自动拦截): 用代码 grep + JSON scan 精确判定,exit 1 拦截 → R01 / R02 / R05 / R06 / R08 / R16 / R17 / R18 / R19 / R20 / R21。
**软防线** (Rule-Scan sub-agent 识别): 需 LLM 语义判断,输出 `rule-hits.json` 给 UI sub-agent 参考 → R03 / R04 / R07 / R09 / R10 / R11 / R12 / R13 / R14 / R15。
**v1.2.0 对账基座**: R02 / R06 / R17 / R18 / R19 / R20 依赖 `bin/lib/loadCache.mjs` 标注的 `_inBakedSubtree`(整体切图子树)/`_hidden`(隐藏)/`_templateDup`(`.map()` 数据副本),以及 `bin/lib/cssMatch.mjs` 的 SCSS `&__foo` 嵌套匹配。这些标注把"整体切图子树 / 隐藏 / 列表副本 / 嵌套写法"四类假阳性从根源清除,使硬防线报数即真值,校验从"黑名单抽查"升级为"以 cache 为真值逐节点对账"。
## 使用方式
### Rule-Scan sub-agent
派发时的完整 prompt:
```
你是 Rule-Scan sub-agent, 只做规则识别, 不写 UI 代码.
任务:
1. Read templates/skills/pp-d2c/rules/*.md (全部 19 条)
2. Read .d2c-cache/<cache-key>/nodes/ 下与本 block nodeIds 相关的 JSON
3. 对本 block 的每个节点, 判断命中了哪些规则
4. 输出 rule-hits.json (schema 见附)
规则命中判定原则:
- 硬防线规则 (R01/R02/R05/R06/R08/R16/R17/R18/R19/R20/R21): 你也扫,即使 check-rules.mjs 会兜底
- 软防线规则 (R03/R04/R07/R09-R15): 你是唯一识别方
- 排斥条件: 若节点命中高优先级规则, 低优先级规则不再重复列
- 优先级 (由高到低): R21 > R16 > R17 > R02 > R01 > R05 > R11 > R03 > R04 > R07 > R06 > R09 > R08 > R20 > R18 > R19 > R14 > R15 > R13 > R12 > R10(R21 最高:节点不可追溯则其余绑定类规则无从谈起)
输出要求:
- 每个 hit 包含 nodeId / rule / trigger 描述 / expected 描述 / context (关键 JSON 字段抽样)
- 输出 JSON, 不带 markdown 代码块围栏, 不加解释文字
- 落盘到 blocks/{sub}/rule-hits.json
禁止:
- 不允许写 JSX / SCSS
- 不允许改 cache 文件
- 不允许基于"设计意图猜测"命中规则; 只按 rules/*.md "触发条件" 字面判定
```
### UI sub-agent
- Read `blocks/{sub}/rule-hits.json` 里涉及的 R0X.md,按"期望产物"落地
- 生完 JSX + SCSS 后跑:
```bash
node .claude/skills/pp-d2c/bin/check-rules.mjs \
--block blocks/{sub}/ \
--cache-key <fileKey>
```
- exit 0 继续 / exit 1 按 violations 回滚重做 / exit 2 报环境错
- `assets.txt` 追加"rule-hits 消费证明"块(格式见下)
**rule-hits 消费证明格式**:
```
## rule-hits 消费证明 (v1.0.0)
- 输入 rule-hits 条数: N
- 处理到位条数: M (M == N 时 ✅)
- 处理列表:
- { nodeId, rule: "R0X", 落地类型: "css 属性" | "span 包裹" | "切图挂父" | ... }
- 遗漏补捕: K 条
- [遗漏补捕] R0X {nodeId} "{name}": Rule-Scan 未识别, 自动补齐落地 = {做了什么}
- check-rules.mjs 通过: ✅ / ❌ + violations 列表
```
### check-rules.mjs
- **硬编码 R01/R02/R05/R06/R08/R16/R17/R18/R19/R20 逻辑**,rules/*.md 是设计文档,不是执行文档
- 假阳性时用 `--force-skip R0X,R0Y` 跳过,但 UI sub-agent 必须在 `assets.txt` 备注 `[脚本误判] R0X {nodeId} 理由: ...`
- 详细 CLI 见 `templates/skills/pp-d2c/bin/check-rules.mjs --help`
## 排斥关系图
```
R02 (fills-image) ─┬─► R11 (mask-vector-css-able): 已切图不再判 CSS-able
└─► R09 (btn-bgc): btn 内 fills IMAGE 走 R09 优先
R01 (fixed-position) ── R14 (fixed-z-index): 多个 fixed 才判 R14
R06 (text-solid-last) ─┬─► R04 (text-gradient): 末位是 GRADIENT/IMAGE 归 R04
└─► R10 (no-fake-solid-color): R06 命中即已核对色源
R05 (space-between) ── (无排斥)
R08 (bg-landing-form) ── (无排斥,反向匹配所以自成一体)
R03 (implicit-image) ── R11 (mask-vector-css-able): R03 覆盖后者的常见形态
R12 (flat-mode-naming) ── (无排斥,只影响 merge.mode='flat')
R13 (unit-scale) ── (无排斥,单位)
R15 (同构 map) ── (无排斥,结构)
R16 (no-flatten-text) ── 最高优先级; 命中 R16 时 R02/R06 若源自同一"整体切图"违规则视为衍生, 不重复报
R17 (no-baked-dom) ── 与 R16 配套(压平 vs 拆两面); R02/R06 跳过 _inBakedSubtree 节点(不报"缺 url/color"), "禁 DOM" 交 R17 正向兜底
R18 (flex-direction) ─┬─ 与 R19 成对(autolayout 容器忠实度); 靠 data-node-id 绑定, 模板项挂代表项 id
R19 (padding) ────────┘
R20 (absolute-position) ── 排斥 fixed-(那走 R01/constraints); 只管非 fixed 的 layoutPositioning:ABSOLUTE
R21 (node-id-coverage) ── 最高优先级; 节点无 data-node-id 则 R06/R18/R19/R20 全绑定不上, 先补 id 再谈其余; 排斥 baked/hidden/templateDup 与 bg-/bgc-/x-(不生成独立 DOM)
```
## 版本
- **v1.2.1** `_inBakedSubtree` 移除 bgc-(bgc- 盒级 CSS 写父、非切图,子孙误放 TEXT 应被 R06/R21 暴露而非静默吞); 新增 **R21 node-id-coverage**(应渲染节点漏挂 data-node-id 即 exit 1,机械强制 §5.1.1 铁律,堵 R18/R19/R20 遇空 classMap 静默 continue 的逃逸); §6.0.2 禁生成流程用 `--force-skip`
- **v1.2.0** 校验范式从"黑名单抽查"→"以 cache 为真值逐节点对账": loadCache 标注 `_inBakedSubtree`/`_hidden`/`_templateDup` + cssMatch 共享 SCSS 嵌套匹配(R02/R06 假阳性根源清除); 新增 R17 no-baked-dom / R18 flex-direction / R19 padding / R20 absolute-position 四条对账规则
- v1.1.0 新增 R16 no-flatten-text 硬防线
- v1.0.0 首次引入 rules/ 目录 + check-rules.mjs
- v0.3.21 之前:硬规则文字散落在 SKILL.md §4.3 等章节
## 相关
- `templates/skills/pp-d2c/SKILL.md` — 主流程
- `templates/skills/pp-d2c/bin/check-rules.mjs` — 硬防线脚本
- `.Knowledge/req-docs/pp-d2c-rule-scan_技术方案.md` — 本轮技术方案
- `.Knowledge/req-docs/pp-d2c-rule-scan_需求澄清.md` — 需求澄清
#!/usr/bin/env python3
"""pp-image-compress: 无损压缩指定文件夹下的 PNG / JPEG 图片。
用法:
python compress.py <folder> # 压缩到 <folder>/compressed/
python compress.py <folder> --dry-run # 预览命中文件与预估节省
python compress.py <folder> --recursive
python compress.py <folder> --overwrite # 直接原地覆盖(不推荐)
python compress.py <folder> --out <dir> # 自定义输出目录
无损策略:
- PNG : Pillow save(optimize=True, compress_level=9) 真·位级无损
- JPEG: Pillow save(quality='keep', optimize=True, progressive=True)
保留原有量化表, 不重新有损编码; 仅重排熵编码, 是 JPEG 层面的无损
依赖: pip install Pillow
"""
from __future__ import annotations
import argparse
import io
import sys
from pathlib import Path
try:
from PIL import Image, ImageFile
except ImportError:
print("[pp-image-compress] 缺少依赖 Pillow, 请先运行: pip install Pillow", file=sys.stderr)
sys.exit(2)
ImageFile.LOAD_TRUNCATED_IMAGES = False
PNG_EXTS = {".png"}
JPEG_EXTS = {".jpg", ".jpeg"}
SUPPORTED = PNG_EXTS | JPEG_EXTS
def human(n: int) -> str:
step = 1024.0
for unit in ("B", "KB", "MB", "GB"):
if abs(n) < step:
return f"{n:.1f}{unit}" if unit != "B" else f"{int(n)}{unit}"
n /= step
return f"{n:.1f}TB"
def compress_png(src: Path) -> bytes:
with Image.open(src) as im:
im.load()
buf = io.BytesIO()
save_kwargs = {"format": "PNG", "optimize": True, "compress_level": 9}
if "icc_profile" in im.info:
save_kwargs["icc_profile"] = im.info["icc_profile"]
im.save(buf, **save_kwargs)
return buf.getvalue()
def compress_jpeg(src: Path) -> bytes:
with Image.open(src) as im:
im.load()
buf = io.BytesIO()
save_kwargs = {
"format": "JPEG",
"quality": "keep",
"optimize": True,
"progressive": True,
}
if "icc_profile" in im.info:
save_kwargs["icc_profile"] = im.info["icc_profile"]
if "exif" in im.info:
save_kwargs["exif"] = im.info["exif"]
im.save(buf, **save_kwargs)
return buf.getvalue()
def gather(folder: Path, recursive: bool) -> list[Path]:
it = folder.rglob("*") if recursive else folder.iterdir()
files: list[Path] = []
for p in it:
if not p.is_file():
continue
if p.suffix.lower() not in SUPPORTED:
continue
parts = set(p.relative_to(folder).parts)
if "compressed" in parts or ".backup" in parts:
continue
files.append(p)
return sorted(files)
def process_one(src: Path) -> bytes | None:
ext = src.suffix.lower()
try:
if ext in PNG_EXTS:
return compress_png(src)
if ext in JPEG_EXTS:
return compress_jpeg(src)
except Exception as e:
print(f" ! 跳过 {src}: {e}", file=sys.stderr)
return None
def main() -> int:
ap = argparse.ArgumentParser(description="Lossless PNG/JPEG compression (Pillow-only).")
ap.add_argument("folder", help="目标图片文件夹")
ap.add_argument("--dry-run", "-n", action="store_true", help="仅预览, 不写盘")
ap.add_argument("--recursive", "-r", action="store_true", help="递归子目录")
ap.add_argument("--overwrite", action="store_true", help="原地覆盖(不推荐, 不与 --out 兼用)")
ap.add_argument("--out", default=None, help="自定义输出目录 (默认 <folder>/compressed)")
args = ap.parse_args()
folder = Path(args.folder).expanduser().resolve()
if not folder.is_dir():
print(f"[pp-image-compress] 目录不存在: {folder}", file=sys.stderr)
return 2
if args.overwrite and args.out:
print("[pp-image-compress] --overwrite 与 --out 互斥", file=sys.stderr)
return 2
out_dir = None
if not args.overwrite:
out_dir = Path(args.out).expanduser().resolve() if args.out else (folder / "compressed")
files = gather(folder, args.recursive)
print(f"[pp-image-compress] folder : {folder}")
print(f"[pp-image-compress] recursive : {args.recursive}")
print(f"[pp-image-compress] mode : {'dry-run' if args.dry_run else ('overwrite' if args.overwrite else 'copy-out')}")
if out_dir:
print(f"[pp-image-compress] output : {out_dir}")
print(f"[pp-image-compress] candidates : {len(files)}")
if not files:
return 0
total_before = 0
total_after = 0
hit = 0
skipped_no_gain = 0
for src in files:
rel = src.relative_to(folder)
before = src.stat().st_size
data = process_one(src)
if data is None:
continue
after = len(data)
total_before += before
# 只有真的变小才算命中; JPEG quality=keep 有时会略大, 那种就保持原图
if after >= before:
total_after += before
skipped_no_gain += 1
print(f" = {rel} {human(before)} → {human(after)} (no gain, 保留原图)")
if not args.dry_run and not args.overwrite:
# copy-out 模式下, 无收益的也复制一份原图, 保证 compressed/ 是完整可替换的副本
dst = out_dir / rel
dst.parent.mkdir(parents=True, exist_ok=True)
dst.write_bytes(src.read_bytes())
continue
total_after += after
hit += 1
saved = before - after
pct = saved * 100.0 / before
print(f" · {rel} {human(before)} → {human(after)} -{human(saved)} ({pct:.1f}%)")
if args.dry_run:
continue
if args.overwrite:
src.write_bytes(data)
else:
dst = out_dir / rel
dst.parent.mkdir(parents=True, exist_ok=True)
dst.write_bytes(data)
saved_total = total_before - total_after
pct_total = (saved_total * 100.0 / total_before) if total_before else 0.0
print("")
print(f"[pp-image-compress] processed : {len(files)}")
print(f"[pp-image-compress] compressed : {hit}")
print(f"[pp-image-compress] no-gain : {skipped_no_gain}")
print(f"[pp-image-compress] before : {human(total_before)}")
print(f"[pp-image-compress] after : {human(total_after)}")
print(f"[pp-image-compress] saved : {human(saved_total)} ({pct_total:.1f}%)")
if args.dry_run:
print("[pp-image-compress] dry-run 完成, 未写盘")
return 0
if __name__ == "__main__":
sys.exit(main())
# pp-image-compress Skill
> 无损压缩指定文件夹下的 PNG / JPEG 图片,输出到 `<folder>/compressed/` 子目录,原图不动。**纯 Python + Pillow 方案**,零系统依赖。
## 触发条件
- 用户输入图片文件夹路径 + 明确要求"压缩图片 / 无损压缩 / 优化图片体积"
- 用户说:「帮我压一下这个文件夹的图片」「无损压缩这批图」「优化 assets 体积」
- 直接 `$pp-image-compress <folder>`
## 前置依赖
```bash
pip install Pillow
```
未安装会退出码 2 并提示。
## 执行流程
### 步骤 0:确认目标文件夹
从用户输入拿到图片目录的绝对/相对路径。**默认只扫顶层**,需要递归时加 `--recursive`。
**禁止**在没有明确路径时假设默认目录(不同于 pp-strip-nodeid,这个 skill 没有配置文件兜底)。
### 步骤 1:先跑 dry-run 预览
**必须**先跑 dry-run,让用户看清将处理多少文件、预计节省多少体积:
```bash
python .claude/skills/pp-image-compress/compress.py <folder> --dry-run
```
输出示例:
```
[pp-image-compress] folder : /abs/path/to/imgs
[pp-image-compress] recursive : False
[pp-image-compress] mode : dry-run
[pp-image-compress] output : /abs/path/to/imgs/compressed
[pp-image-compress] candidates : 12
· hero.png 482.3KB → 361.7KB -120.6KB (25.0%)
· icon.png 8.2KB → 6.1KB -2.1KB (25.6%)
= photo.jpg 1.2MB → 1.2MB (no gain, 保留原图)
...
[pp-image-compress] processed : 12
[pp-image-compress] compressed : 10
[pp-image-compress] no-gain : 2
[pp-image-compress] before : 8.4MB
[pp-image-compress] after : 6.1MB
[pp-image-compress] saved : 2.3MB (27.4%)
[pp-image-compress] dry-run 完成, 未写盘
```
### 步骤 2:用户确认后实际写盘
用户确认无误后,去掉 `--dry-run` 重跑:
```bash
python .claude/skills/pp-image-compress/compress.py <folder>
```
**默认行为**:写入 `<folder>/compressed/`,保持相对子路径。原图保持原样不动。
**同名保留**:文件名与相对路径完全保持一致,方便一键替换原目录(`cp -r compressed/. .`)。
### 步骤 3:产出摘要
脚本自身会打印:候选数、命中数、无收益数、压缩前后总体积、节省百分比。Agent 侧只需转述关键指标并建议下一步(例如"确认无误后可用 `cp -r <folder>/compressed/. <folder>/` 覆盖原图,或用 git 复核 diff")。
## 参数
| 参数 | 说明 |
|------|------|
| `<folder>` | **必填**。目标图片文件夹(绝对或相对路径) |
| `--dry-run` / `-n` | 只预览,不写盘 |
| `--recursive` / `-r` | 递归处理子目录 |
| `--out <dir>` | 自定义输出目录(默认 `<folder>/compressed/`) |
| `--overwrite` | 原地覆盖原图(危险,与 `--out` 互斥;仅在原图已有别的备份时使用) |
## 无损策略说明
- **PNG**:`Pillow.save(optimize=True, compress_level=9)` — deflate 最高档 + 熵优化,**像素级完全无损**,仅重排压缩流。ICC profile 保留。
- **JPEG**:`Pillow.save(quality='keep', optimize=True, progressive=True)` — 保留原图量化表,**不重编码**,仅重排 Huffman 熵编码为渐进式。这是 JPEG 层面的无损(像素一致性依赖原有量化表)。ICC / EXIF 保留。
**收益预期**:
- PNG:典型 5%–30%(已经 optipng 过的会没收益)
- JPEG:典型 2%–10%(相机原片通常收益更大,已优化过的接近 0)
**无收益保护**:某些图片压缩后反而变大(罕见但存在),脚本自动保留原图字节,输出 `no gain, 保留原图`。copy-out 模式下 compressed/ 里放的仍是原图字节,保证目录整体可替换。
## 支持范围
- ✅ `.png` — Pillow 处理
- ✅ `.jpg` / `.jpeg` — Pillow 处理
- ❌ 其他扩展名(`.webp` / `.gif` / `.svg`)当前版本不处理,遇到自动跳过
自动跳过:`compressed/`、`.backup/` 子目录(避免重复处理)。
## 禁止
- 禁止跳过 dry-run 直接写盘:即使默认输出到 `compressed/` 不改原图,也必须先给用户看命中列表与预估节省,避免对着 `~/Downloads` 或错误目录跑几千张图
- 禁止用 `--overwrite` 作为默认路径:这个参数只在用户明确说"原地压缩 / 覆盖 / 我有别的备份"时才允许,其它情况下必须走默认的 `compressed/` 输出
- 禁止对 `node_modules/`、`.git/`、`dist/`、`build/` 里的图片跑压缩:调用前 Agent 需要判断路径合理性,用户若给了工程根目录就要反问确认
- 禁止修改 quality 参数为具体数字(例如 `quality=85`):那会把 JPEG 从"无损重编码"变成"有损重编码",违反 skill 语义。真需要有损压缩请另开 skill
+0
-29

@@ -699,32 +699,3 @@ #!/usr/bin/env node

},
layers: {
sub: 'sub-',
block: 'block-',
img: 'img-',
bg: 'bg-',
bgColor: 'bgc-',
but: 'btn-',
scrollX: 'scrollx-',
scrollY: 'scrolly-',
fixed: 'fixed-',
end: 'end-',
input: 'input-',
ignore: 'x-',
...(existing.layers || {})
},
output: { dir: outputDir },
// rn 项目默认关闭 doctor(不接卫星 SKILL);react 项目保留完整 health 段
health: framework === 'rn'
? (existing.health || { enabled: false })
: (existing.health || {
enabled: true,
blockOnError: true,
report: { markdown: true, json: true, dir: '' },
thresholds: {
maxDepth: 6, subBlockMin: 3, subBlockMax: 20, totalNodesMax: 1500,
hiddenRatioMax: 0.2, paddingAsymmetryMax: 32,
bgSizeMin: 0.8, bgSizeMax: 1.2, colorDeltaEMin: 3
},
rules: {}
}),
// 【新增】rn 分支写 adapter 段;react 项目不写

@@ -731,0 +702,0 @@ ...(framework === 'rn' ? { adapter: adapterCfg } : {})

+1
-1
{
"name": "@double-coding/pixel-print",
"version": "1.2.7",
"version": "1.3.0",
"description": "PixelPrint(像素打印)—— Figma D2C 工具,一键安装 Claude Code Skill,像素级还原设计稿为前端代码(H5 / React Native / xtaro)",

@@ -5,0 +5,0 @@ "bin": {

@@ -21,38 +21,5 @@ {

},
"layers": {
"sub": "sub-",
"block": "block-",
"img": "img-",
"bg": "bg-",
"bgColor": "bgc-",
"but": "btn-",
"scrollX": "scrollx-",
"scrollY": "scrolly-",
"ignore": "x-"
},
"output": {
"dir": "pages/"
},
"health": {
"enabled": true,
"blockOnError": true,
"report": {
"markdown": true,
"json": true,
"dir": ""
},
"thresholds": {
"maxDepth": 6,
"subBlockMin": 3,
"subBlockMax": 20,
"totalNodesMax": 1500,
"hiddenRatioMax": 0.2,
"paddingAsymmetryMax": 32,
"bgSizeMin": 0.8,
"bgSizeMax": 1.2,
"colorDeltaEMin": 3
},
"rules": {}
}
}

Sorry, the diff of this file is too big to display