PBIX 대시보드 패처
Power BI Desktop .pbix 파일을 직접 편집합니다: 페이지 추가, 시각적 개체 추가, 레이아웃 변경, 필드 재바인딩 — 그런 다음 Power BI Desktop에서 손상 오류 없이 열 수 있도록 다시 패키징합니다.
사용 시기
- 사용자가 저장된
.pbix파일에 시각적 개체를 추가하거나 대시보드를 생성하려는 경우 - 사용자가 Power BI 보고서 편집을 자동화하려는 경우 (CI, 대량 템플릿, 데이터 기반 레이아웃)
- 이전 편집 시도에서
MashupValidationError,Invalid SecurityBindings또는 "파일 손상" 오류가 발생한 경우
핵심 문제 (먼저 읽으세요)
최신 .pbix 파일은 PBIR Enhanced Report Format을 사용합니다. 보고서 레이아웃은 기존의 단일 Report/Layout 파일이 아닌 Report/definition/pages/<pageId>/visuals/<visualId>/visual.json에 시각적 개체별 JSON 파일로 저장됩니다.
PBIX는 다음을 포함하는 ZIP 파일입니다:
DataModel— Analysis Services 모델 (재압축하면 안 됨; 그대로 저장)Report/definition/...— PBIR 시각적 개체별 JSON 트리SecurityBindings— 파일 레이아웃에 서명하는 DPAPI 암호화 매니페스트[Content_Types].xml— ZIP 내 모든 파일 목록
파일을 손상시키는 함정 (교훈을 얻으세요):
- Python
zipfile은 .NET의ZipArchive와 다른 외부 파일 속성을 설정합니다. Power BI는 이를 검증합니다. PowerShell을 통해 .NETZipArchive를 사용하고 모든 항목에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 오류:BOM(바이트 순서 표시)이 없는 UTF8 인코딩 텍스트만 지원됩니다. 감지된 BOM: 'UTF-8'. 명시적 인코딩 없이Set-Content -Encoding UTF8및File.WriteAllText는 Windows PowerShell 5.1 / .NET Framework에서 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있음 - 레거시:
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자리 16진수 ID이며 페이지 내에서 고유해야 합니다.visualType예시:cardVisual,clusteredBarChart,clusteredColumnChart,lineChart,donutChart,pieChart,tableEx,pivotTable.cardVisual은Data역할을 사용합니다. 막대/세로 막대/꺾은선형 차트는Category+Y(+ 선택적Series)를 사용합니다. 도넛/원형 차트는Category+Y를 사용합니다.Aggregation.Function: 0=합계, 1=평균, 2=개수, 3=최소, 4=최대, 5=CountNonNull, 6=중앙값, 7=StDev, 8=분산.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을 사용하지 마세요. .NET ZipArchive와 함께 PowerShell을 사용하세요. 주요 세부 사항:
- 항목 이름은 슬래시를 사용하며 앞에
/가 없습니다. (Get-Item $src).FullName.TrimEnd('\').Length + 1에서 소스 접두사 길이를 계산합니다 (Get-ChildItem 항목의FullName과 동일한 대소문자/긴 경로 형식). 모든 항목 이름에TrimStart('/')를 보험으로 적용합니다.- 모든 텍스트 항목 (
[Content_Types].xml포함)은 UTF-8 BOM 없는StreamWriter로 작성됩니다.
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 }
# SecurityBindings 참조를 제거하도록 Content_Types.xml 편집
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가 거부하는 0이 아닌 값을 설정합니다
$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— 모든 계열의 기본 대체 색상입니다.
…
[더 이상 번역되지 않음]