PBIX 仪表板修补器
就地编辑 Power BI Desktop 的 .pbix 文件:添加页面、添加视觉对象、更改布局、重新绑定字段 — 然后重新打包,让 Power BI Desktop 打开时不会出现损坏错误。
何时使用
- 用户有一个已保存的
.pbix文件,并希望添加视觉对象/生成仪表板。 - 用户希望自动化 Power BI 报告编辑(CI、批量模板化、数据驱动的布局)。
- 之前的编辑尝试导致了
MashupValidationError、Invalid SecurityBindings或打开文件时出现“文件损坏”。
核心问题(请先阅读此部分)
现代 .pbix 文件使用 PBIR 增强报告格式:报告布局以单个视觉对象的 JSON 文件形式存储在 Report/definition/pages/<pageId>/visuals/<visualId>/visual.json 中,而不是旧的单体式 Report/Layout。
一个 PBIX 是一个 ZIP 文件,包含(但不限于):
DataModel— Analysis Services 模型(必须不重新压缩;原样存储)。Report/definition/...— PBIR 按视觉对象的 JSON 树。SecurityBindings— 一个 DPAPI 加密的清单,用于签署文件布局。[Content_Types].xml— 列出 ZIP 中的每个文件。
导致文件损坏的陷阱(从惨痛教训中总结):
- Python 的
zipfile设置了与 .NET 的ZipArchive不同的外部文件属性。 Power BI 会验证这些属性。请使用 .NET 的ZipArchive(通过 PowerShell),每个条目的ExternalAttributes都设为0。 - 重新压缩
DataModel会破坏它。 它必须使用与源文件相同的压缩方法存储(通常是Deflate,但有时是Stored);最安全的方法是在你重写时使用NoCompression,否则原样保留原始字节。 - 添加任何文件都会破坏
SecurityBindings。 它是一个已签名的文件内容清单。如果你添加了Report/definition/pages/<new>/visuals/.../visual.json,签名不再匹配,PBI 将拒绝打开。 - 条目的名称不能以
/开头。 如果在打开文件时遇到MissingBIPackagePart: /Version错误,表示你的Version条目被压缩成了/Version(首字母斜杠)。PowerShell 的朴素Substring($src.Length + 1)方法会留下一个首字母反斜杠,然后在 ZIP 中变成/,导致 PBI 拒绝。请根据解析后的长路径计算前缀,并确保每个条目名称都去掉了前缀的/。 - 在
Report/definition/**/*.json中使用 UTF-8 BOM 是致命的。 PBI 报错:Only text with UTF8 encoding without BOM (byte order marks) is supported. Detected BOM: 'UTF-8'。在 Windows PowerShell 5.1 / .NET Framework 中,Set-Content -Encoding UTF8和File.WriteAllText(未显式指定编码)都会写入 BOM。始终使用New-Object System.Text.UTF8Encoding($false)传递给WriteAllText或StreamWriter来写入 JSON。
解决 SecurityBindings 问题的方法: 从 ZIP 中剥离它,并从 [Content_Types].xml 中删除其条目。Power BI Desktop 在下次保存时会重新生成 SecurityBindings。文件将能正常打开。
步骤
1. 检查文件
# 使用 .NET 提取到工作目录,以便属性正确返回
Add-Type -AssemblyName System.IO.Compression.FileSystem
[System.IO.Compression.ZipFile]::ExtractToDirectory("Test.pbix", "pbix_src")
检查是 PBIR(新)还是旧版:
- PBIR:存在
Report\definition\pages\<pageId>\visuals\<visualId>\visual.json。 - 旧版(Legacy):仅存在
Report\Layout(二进制 JSON 字符串)。该技能针对 PBIR。
2. 理解 visual.json 模式(版本 2.12.0)
最小的 cardVisual(KPI 磁贴):
{
"$schema": "https://developer.microsoft.com/json-schemas/fabric/item/report/definition/visualContainer/2.12.0/schema.json",
"name": "<20-hex-char id>",
"position": {"x": 20, "y": 20, "z": 0, "height": 160, "width": 440, "tabOrder": 0},
"visual": {
"visualType": "cardVisual",
"query": {
"queryState": {
"Data": {
"projections": [{
"field": {
"Aggregation": {
"Expression": {"Column": {"Expression": {"SourceRef": {"Entity": "financials"}}, "Property": "Sales"}},
"Function": 0
}
},
"queryRef": "Sum(financials.Sales)",
"nativeQueryRef": "Sum of Sales",
"active": true
}]
}
},
"sortDefinition": {
"sort": [{"field": {"Aggregation": {"Expression": {"Column": {"Expression": {"SourceRef": {"Entity": "financials"}}, "Property": "Sales"}}, "Function": 0}}, "direction": "Descending"}],
"isDefaultSort": true
}
},
"drillFilterOtherVisuals": true
}
}
关键事实:
name是一个 20 个字符的十六进制 ID;必须在页面内唯一。visualType示例:cardVisual、clusteredBarChart、clusteredColumnChart、lineChart、donutChart、pieChart、tableEx、pivotTable。cardVisual使用角色Data。条形图/柱状图/折线图使用Category+Y(可选Series)。环形图/饼图使用Category+Y。Aggregation.Function:0=Sum,1=Avg,2=Count,3=Min,4=Max,5=CountNonNull,6=Median,7=StDev,8=Variance。drillFilterOtherVisuals: true是必需的;省略会导致文件损坏。
3. 注册页面
Report/definition/pages/pages.json:
{"$schema":"...pagesMetadata/1.1.0/schema.json","pageOrder":["<pageId1>","<pageId2>"],"activePageName":"<pageIdX>"}
每个页面文件夹都有一个 page.json:
{"$schema":"...page/2.1.0/schema.json","name":"<pageId>","displayName":"Dashboard","displayOption":"FitToPage","height":1080,"width":1920}
4. 写入 JSON 文件 — UTF-8 无 BOM
每次编辑 visual.json、page.json、report.json 或 Report/definition/ 下的任何文件时,都需要使用无 BOM 编码写入。Windows PowerShell 5.1 默认会添加 BOM。
$utf8NoBom = New-Object System.Text.UTF8Encoding($false)
[System.IO.File]::WriteAllText($path, $json, $utf8NoBom)
要移除之前错误写入的 BOM:
$b = [System.IO.File]::ReadAllBytes($path)
if ($b.Length -ge 3 -and $b[0] -eq 0xEF -and $b[1] -eq 0xBB -and $b[2] -eq 0xBF) {
$text = [System.Text.Encoding]::UTF8.GetString($b, 3, $b.Length - 3)
[System.IO.File]::WriteAllText($path, $text, (New-Object System.Text.UTF8Encoding($false)))
}
5. 重新打包 — 唯一可靠的方法
不要使用 Python 的 zipfile。请使用带有 .NET ZipArchive 的 PowerShell。关键细节:
- 条目名称使用正斜杠,且不能以
/开头。 - 从
(Get-Item $src).FullName.TrimEnd('\').Length + 1计算源前缀长度(使用与Get-ChildItem项的FullName相同的大小写/长路径形式)。安全起见,每个条目名称都进行TrimStart('/')操作。 - 每个文本条目(包括
[Content_Types].xml)都使用 UTF-8 无 BOMStreamWriter写入。
Add-Type -AssemblyName System.IO.Compression
$src = "pbix_src"
$dst = "Test_patched.pbix"
if (Test-Path $dst) { Remove-Item $dst }
$srcFull = (Get-Item $src).FullName.TrimEnd('\')
$prefixLen = $srcFull.Length + 1
$utf8NoBom = New-Object System.Text.UTF8Encoding($false)
$fs = [System.IO.File]::Open($dst, 'Create')
$zip = New-Object System.IO.Compression.ZipArchive($fs, [System.IO.Compression.ZipArchiveMode]::Create)
Get-ChildItem $src -Recurse -File | ForEach-Object {
$full = $_.FullName
$rel = $full.Substring($prefixLen).Replace('\','/').TrimStart('/')
# 剥离 SecurityBindings — PBI 会在保存时重新生成
if ($rel -eq 'SecurityBindings') { return }
# 编辑 Content_Types.xml 以删除 SecurityBindings 引用
if ($rel -eq '[Content_Types].xml') {
$xml = [System.IO.File]::ReadAllText($full) -replace '<Override[^/]*PartName="/SecurityBindings"[^/]*/>',''
$entry = $zip.CreateEntry($rel, [System.IO.Compression.CompressionLevel]::Optimal)
$entry.ExternalAttributes = 0
$sw = New-Object System.IO.StreamWriter($entry.Open(), $utf8NoBom)
$sw.Write($xml); $sw.Close()
return
}
# DataModel 必须不重新压缩
$level = if ($rel -eq 'DataModel') {
[System.IO.Compression.CompressionLevel]::NoCompression
} else {
[System.IO.Compression.CompressionLevel]::Optimal
}
$entry = $zip.CreateEntry($rel, $level)
$entry.ExternalAttributes = 0 # 关键 — Python 会将其设置为非零值,PBI 拒绝
$es = $entry.Open()
$sfs = [System.IO.File]::OpenRead($full)
$sfs.CopyTo($es)
$sfs.Close(); $es.Close()
}
$zip.Dispose(); $fs.Close()
6. 打开前验证
四项检查,按顺序进行:
Add-Type -AssemblyName System.IO.Compression.FileSystem
$z = [System.IO.Compression.ZipFile]::OpenRead("Test_patched.pbix")
# a) 必须的部分存在,SecurityBindings 不存在
$z.Entries | Where-Object { $_.FullName -match '^(Version|DataModel|Metadata|Settings|SecurityBindings|\[Content_Types\]\.xml)$' } |
Select-Object FullName, Length
# b) 没有条目以 '/' 开头
$z.Entries | Where-Object { $_.FullName.StartsWith('/') } | ForEach-Object { "错误: $($_.FullName)" }
# c) 任何文本元数据或 Report/definition JSON 中没有 BOM
foreach ($e in $z.Entries) {
if ($e.FullName -match '\.(json|xml)$|^Version$|^Metadata$|^Settings$') {
$s = $e.Open(); $ms = New-Object System.IO.MemoryStream; $s.CopyTo($ms); $s.Close()
$b = $ms.ToArray()
if ($b.Length -ge 3 -and $b[0] -eq 0xEF -and $b[1] -eq 0xBB -and $b[2] -eq 0xBF) {
Write-Host "发现 BOM: $($e.FullName)"
}
}
}
# d) 每个 Report/definition JSON 都能解析
foreach ($e in $z.Entries) {
if ($e.FullName -like 'Report/definition/*.json' -or $e.FullName -like 'Report/definition/**/*.json') {
$s = $e.Open(); $sr = New-Object System.IO.StreamReader($s)
$txt = $sr.ReadToEnd(); $sr.Close()
try { $null = $txt | ConvertFrom-Json } catch { "无效的 JSON: $($e.FullName) — $($_.Exception.Message)" }
}
}
$z.Dispose()
预期结果:应存在 DataModel、Version、Metadata、Settings、[Content_Types].xml;不存在 SecurityBindings;无前导斜杠;无 BOM;所有 JSON 都能解析。
连接到实时 Power BI Desktop 会话(可选 — 用于数据发现)
如果 PBI Desktop 正在运行,它会在 localhost 上公开一个 Analysis Services 端点:
# 发现端口
Get-Process msmdsrv | ForEach-Object {
$port = Get-NetTCPConnection -OwningProcess $_.Id -State Listen |
Where-Object { $_.LocalAddress -eq '127.0.0.1' } |
Select-Object -First 1 -ExpandProperty LocalPort
Write-Host "Analysis Services 端口: $port"
}
然后通过 ADOMD 连接(需要安装 Power BI Desktop;DLL 随它一起提供):
# pip install pyadomd pythonnet
import clr
clr.AddReference(r"C:\Program Files\Microsoft Power BI Desktop\bin\Microsoft.PowerBI.AdomdClient.dll")
from Microsoft.AnalysisServices.AdomdClient import AdomdConnection # 真正的命名空间,非文件名
conn = AdomdConnection(f"Data Source=localhost:{port}")
conn.Open()
# 运行 DAX: EVALUATE SUMMARIZECOLUMNS(financials[Segment], "Sales", SUM(financials[Sales]))
仅用于读取模式和预览数字;最终的视觉对象通过上述 JSON 文件写入并重新打包。
样式化视觉对象 — 实际有效的属性名称
Power BI Desktop 中的格式窗格标签不匹配 JSON 属性名称。如果你猜测使用,PBI 会在下次保存时静默地丢弃伪造的属性 — 文件可以正常打开,但你的样式会消失。始终在 PBI 中构建一次真实的更改,保存,提取,然后从保存的 JSON 中复制属性名称。
样式在 visual.json 中的位置
visual.visualContainerObjects— 视觉对象磁贴的外框(背景、边框、投影、标题栏)。适用于每种视觉对象类型。visual.objects— 视觉对象的内部(绘图区、坐标轴、图例、数据点、卡片值、卡片内部填充)。此处的属性名称因视觉对象类型而异,并且必须匹配主题的visualStyles[visualType]模式。
容器框架(适用于每种视觉对象)
"visualContainerObjects": {
"background": [{"properties":{
"color":{"solid":{"color":{"expr":{"Literal":{"Value":"'#EEF2FF'"}}}}},
"transparency":{"expr":{"Literal":{"Value":"0D"}}}
}}],
"border": [{"properties":{
"show":{"expr":{"Literal":{"Value":"true"}}},
"color":{"solid":{"color":{"expr":{"Literal":{"Value":"'#E2E8F0'"}}}}},
"radius":{"expr":{"Literal":{"Value":"12D"}}}
}}],
"dropShadow": [{"properties":{
"show":{"expr":{"Literal":{"Value":"true"}}},
"color":{"solid":{"color":{"expr":{"Literal":{"Value":"'#0F172A'"}}}}},
"transparency":{"expr":{"Literal":{"Value":"85D"}}},
"position":{"expr":{"Literal":{"Value":"'Outer'"}}},
"preset":{"expr":{"Literal":{"Value":"'BottomRight'"}}}
}}]
}
cardVisual(KPI 磁贴)— 陷阱
大数字不是 callout.color 也不是 value.color。PBI 会静默地丢弃这两个属性。
- 大字体的颜色:
value.fontColor(不是color)。 - 大字体的字体/大小/粗细:
value.fontFamily、value.fontSize、value.bold。 - 数字下方的类别标签:
label.fontColor、label.fontFamily、label.fontSize。 - 数字背后的着色填充:
cardCalloutArea.backgroundFillColor,并带有一个selector,该选择器指定度量投影的元数据 — 例如"selector":{"metadata":"Sum(financials.Profit)"}。从visual.query.queryState.Data.projections[0].queryRef读取该字符串。
"objects": {
"value": [{"properties":{
"fontColor":{"solid":{"color":{"expr":{"Literal":{"Value":"'#4F46E5'"}}}}},
"fontFamily":{"expr":{"Literal":{"Value":"'Segoe UI Semibold'"}}},
"fontSize":{"expr":{"Literal":{"Value":"40D"}}},
"bold":{"expr":{"Literal":{"Value":"true"}}}
}}],
"label": [{"properties":{
"fontColor":{"solid":{"color":{"expr":{"Literal":{"Value":"'#64748B'"}}}}}
}}],
"cardCalloutArea": [{
"properties": {
"show":{"expr":{"Literal":{"Value":"true"}}},
"backgroundFillColor":{"solid":{"color":{"expr":{"Literal":{"Value":"'#E0E7FF'"}}}}}
},
"selector":{"metadata":"Sum(financials.Sales)"}
}]
}
条形图/柱状图/折线图/环形图 — 数据颜色
dataPoint.defaultColor— 后备颜色...