PBIX 儀表板修補程式
原地編輯 Power BI Desktop .pbix 檔案:新增頁面、新增視覺效果、變更版面配置、重新繫結欄位 — 然後重新打包,讓 Power BI Desktop 開啟時不會出現損毀錯誤。
使用時機
- 使用者有儲存好的
.pbix檔案,並希望新增視覺效果或產生儀表板。 - 使用者想要自動化 Power BI 報表編輯(CI、批次範本、資料驅動版面配置)。
- 先前的編輯嘗試產生
MashupValidationError、Invalid SecurityBindings,或開啟時顯示「檔案損毀」。
核心問題(請先閱讀)
現代的 .pbix 檔案使用 PBIR Enhanced Report Format:報表版面配置儲存為每個視覺效果的 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。 它是一個已簽署的操作清單,列出了 ZIP 中的內容。如果您新增Report/definition/pages/<new>/visuals/.../visual.json,簽章將不再匹配,PBI 將拒絕開啟。 - 條目名稱不得有前導
/。 開啟時出現MissingBIPackagePart: /Version表示您的Version條目被壓縮為/Version(前導斜線)。PowerShell 的Substring($src.Length + 1)可能會留下前導反斜線 → 在 zip 中變成/→ PBI 拒絕。從解析的長路徑計算前綴,並對每個條目名稱執行TrimStart('/')。 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。始終使用傳遞給WriteAllText或StreamWriter的New-Object System.Text.UTF8Encoding($false)來寫入 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。 - 舊版:只有
Report\Layout(二進位 JSON 字串)。此技能以 PBIR 為目標。
2. 了解 visual.json 結構(版本 2.12.0)
最小卡片視覺效果(KPI 磚):
{
"$schema": "https://developer.microsoft.com/json-schemas/fabric/item/report/definition/visualContainer/2.12.0/schema.json",
"name": "<20 個十六進位字元的 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 檔案 — 無 BOM 的 UTF-8
每次編輯 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。請使用 PowerShell 搭配 .NET 的 ZipArchive。關鍵細節:
- 條目名稱使用正斜線,且沒有前導
/。 - 從
(Get-Item $src).FullName.TrimEnd('\').Length + 1計算來源前綴長度(與Get-ChildItem項目上FullName的大小寫/長路徑形式相同)。對每個條目名稱執行TrimStart('/')作為保險。 - 每個文字條目(包括
[Content_Types].xml)都使用無 BOM 的 UTF-8StreamWriter寫入。
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— 備用色