O que Patcher de Dashboard PBIX faz?
Edite um arquivo .pbix do Power BI Desktop: adicione páginas, visuais, altere layout, religue campos — e depois empacote-o novamente para que o Power BI Desktop o abra sem erros de corrupção.
Quando usar
- O usuário tem um
.pbixsalvo e deseja adicionar visuais / gerar um dashboard. - O usuário quer automatizar edições de relatórios do Power BI (CI, templates em lote, layout orientado a dados).
- Uma tentativa de edição anterior produziu
MashupValidationError,Invalid SecurityBindingsou "arquivo corrompido" ao abrir.
O problema central (leia isso primeiro)
Arquivos .pbix modernos usam o Formato de Relatório Aprimorado PBIR: o layout do relatório é armazenado como arquivos JSON por visual em Report/definition/pages/<pageId>/visuals/<visualId>/visual.json, não no antigo Report/Layout monolítico.
Um PBIX é um ZIP contendo (entre outros):
DataModel— o modelo Analysis Services (NÃO deve ser recompactado; mantenha como está).Report/definition/...— árvore JSON PBIR por visual.SecurityBindings— um manifesto criptografado por DPAPI que assina o layout do arquivo.[Content_Types].xml— lista todos os arquivos no ZIP.
Armadilhas que corrompem o arquivo (aprenda com as cicatrizes):
- O
zipfiledo Python define atributos de arquivo externos diferentes doZipArchivedo .NET. O Power BI os valida. UseZipArchivedo .NET via PowerShell, comExternalAttributes = 0em cada entrada. - Recomprimir o
DataModelo quebra. Ele deve ser armazenado com o mesmo método de compressão da origem (geralmenteDeflate, mas às vezesStored); o mais seguro éNoCompressionse você o reescreveu, caso contrário, preserve os bytes originais na íntegra. - Adicionar qualquer arquivo quebra o
SecurityBindings. É um manifesto assinado do que está no ZIP. Se você adicionarReport/definition/pages/<new>/visuals/.../visual.json, a assinatura não corresponderá mais e o PBI se recusará a abrir. - Os nomes das entradas NÃO devem ter uma
/inicial.MissingBIPackagePart: /Versionao abrir significa que sua entradaVersionfoi zipada como/Version(barra inicial). OSubstring($src.Length + 1)ingênuo do PowerShell deixa uma barra invertida inicial → torna-se/no zip → o PBI rejeita. Calcule o prefixo a partir do caminho longo resolvido e useTrimStart('/')em cada nome de entrada. - BOM UTF-8 em
Report/definition/**/*.jsoné fatal. Erro PBI:Only text with UTF8 encoding without BOM (byte order marks) is supported. Detected BOM: 'UTF-8'.Set-Content -Encoding UTF8eFile.WriteAllTextsem uma codificação explícita AMBOS escrevem um BOM no Windows PowerShell 5.1 / .NET Framework. Sempre escreva JSON comNew-Object System.Text.UTF8Encoding($false)passado paraWriteAllTextouStreamWriter.
A correção para SecurityBindings: remova-o do ZIP e remova sua entrada de [Content_Types].xml. O Power BI Desktop regenera o SecurityBindings no próximo Salvamento. O arquivo abre perfeitamente.
Procedimento
1. Inspecione o arquivo
# Extrair para um diretório de trabalho usando .NET para que os atributos voltem limpos
Add-Type -AssemblyName System.IO.Compression.FileSystem
[System.IO.Compression.ZipFile]::ExtractToDirectory("Test.pbix", "pbix_src")
Verifique se é PBIR (novo) ou legado:
- PBIR: tem
Report\definition\pages\<pageId>\visuals\<visualId>\visual.json. - Legado: tem apenas
Report\Layout(string JSON binária). Este skill é direcionado a PBIR.
2. Entenda o esquema visual.json (versão 2.12.0)
cardVisual mínimo (tile 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
}
}
Fatos principais:
nameé um id hexadecimal de 20 caracteres; deve ser único dentro da página.visualTypeexemplos:cardVisual,clusteredBarChart,clusteredColumnChart,lineChart,donutChart,pieChart,tableEx,pivotTable.cardVisualusa a funçãoData. Barra/coluna/linha usamCategory+Y(+ opcionalSeries). Donut/pizza usamCategory+Y.Aggregation.Function: 0=Soma, 1=Média, 2=Contagem, 3=Mín, 4=Máx, 5=ContagemNonNull, 6=Mediana, 7=StDev, 8=Variância.drillFilterOtherVisuals: trueé obrigatório; omiti-lo corrompe o arquivo.
3. Registre a página
Report/definition/pages/pages.json:
{"$schema":"...pagesMetadata/1.1.0/schema.json","pageOrder":["<pageId1>","<pageId2>"],"activePageName":"<pageIdX>"}
Cada pasta de página tem um page.json:
{"$schema":"...page/2.1.0/schema.json","name":"<pageId>","displayName":"Dashboard","displayOption":"FitToPage","height":1080,"width":1920}
4. Escreva arquivos JSON — UTF-8 SEM BOM
Toda vez que você editar visual.json, page.json, report.json ou qualquer arquivo em Report/definition/, escreva-o sem BOM. O Windows PowerShell 5.1 adiciona um BOM por padrão.
$utf8NoBom = New-Object System.Text.UTF8Encoding($false)
[System.IO.File]::WriteAllText($path, $json, $utf8NoBom)
Para remover um BOM já escrito por um erro anterior:
$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. Reempacotamento — o ÚNICO método confiável
NÃO use zipfile do Python. Use PowerShell com ZipArchive do .NET. Detalhes importantes:
- Nomes de entrada usam barras normais e NÃO têm
/inicial. - Calcule o comprimento do prefixo fonte a partir de
(Get-Item $src).FullName.TrimEnd('\').Length + 1(mesmo caso/formato de caminho longo queFullNameem itens doGet-ChildItem). UseTrimStart('/')em cada nome de entrada como garantia. - Cada entrada de texto (incluindo
[Content_Types].xml) é escrita com umStreamWriterUTF-8 sem BOM.
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('/')
# REMOVER SecurityBindings — PBI regera ao salvar
if ($rel -eq 'SecurityBindings') { return }
# Editar Content_Types.xml para remover a referência 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 NÃO deve ser recompactado
$level = if ($rel -eq 'DataModel') {
[System.IO.Compression.CompressionLevel]::NoCompression
} else {
[System.IO.Compression.CompressionLevel]::Optimal
}
$entry = $zip.CreateEntry($rel, $level)
$entry.ExternalAttributes = 0 # crítico — Python define isso como um valor não zero que o PBI rejeita
$es = $entry.Open()
$sfs = [System.IO.File]::OpenRead($full)
$sfs.CopyTo($es)
$sfs.Close(); $es.Close()
}
$zip.Dispose(); $fs.Close()
6. Valide antes de abrir
Quatro verificações, em ordem:
Add-Type -AssemblyName System.IO.Compression.FileSystem
$z = [System.IO.Compression.ZipFile]::OpenRead("Test_patched.pbix")
# a) Partes necessárias presentes, SecurityBindings ausente
$z.Entries | Where-Object { $_.FullName -match '^(Version|DataModel|Metadata|Settings|SecurityBindings|\[Content_Types\]\.xml)$' } |
Select-Object FullName, Length
# b) Nenhuma entrada tem '/' inicial
$z.Entries | Where-Object { $_.FullName.StartsWith('/') } | ForEach-Object { "RUIM: $($_.FullName)" }
# c) Nenhum BOM em qualquer metadado de texto ou JSON Report/definition
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 encontrado: $($e.FullName)"
}
}
}
# d) Todo JSON Report/definition é analisável
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 INVÁLIDO: $($e.FullName) — $($_.Exception.Message)" }
}
}
$z.Dispose()
Espere: DataModel, Version, Metadata, Settings, [Content_Types].xml presentes; SecurityBindings ausente; sem barras iniciais; sem BOMs; todos JSONs analisáveis.
Conectando a uma sessão ativa do Power BI Desktop (opcional — para descoberta de dados)
Se o PBI Desktop já estiver em execução, ele expõe um endpoint Analysis Services no localhost:
# Descubra a porta
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 "Porta Analysis Services: $port"
}
Depois conecte via ADOMD (instale o Power BI Desktop; a DLL acompanha):
# 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 # namespace real, não o nome do arquivo
conn = AdomdConnection(f"Data Source=localhost:{port}")
conn.Open()
# Executar DAX: EVALUATE SUMMARIZECOLUMNS(financials[Segment], "Sales", SUM(financials[Sales]))
Use isso apenas para ler esquema e prévia de números; os visuais finais são escritos como arquivos JSON e reempacotados acima.
Estilizando visuais — os nomes de propriedade que realmente funcionam
Os rótulos do painel de formatação no Power BI Desktop NÃO correspondem aos nomes de propriedade JSON. Se você chutar, o PBI silenciosamente remove as propriedades falsas no próximo salvamento — o arquivo abre bem, mas seu estilo desaparece. Sempre construa alterações reais no PBI uma vez, salve, extraia e copie os nomes de propriedade do JSON salvo.
Onde a estilização reside em visual.json
visual.visualContainerObjects— o quadro externo do tile visual (fundo, borda, sombra, barra de título). Aplica-se a todo tipo de visual.visual.objects— o interior do visual (área do gráfico, eixos, legenda, pontos de dados, valor do card, preenchimento interno do card). Os nomes de propriedade aqui são por tipo de visual e devem corresponder ao esquema devisualStyles[visualType]do tema.
Quadro do container (funciona para todo visual)
"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 (tile KPI) — armadilhas
O número grande NÃO é callout.color e NÃO é value.color. O PBI silenciosamente descarta ambos.
- Cor da fonte do número grande:
value.fontColor(nãocolor). - Fonte / tamanho / peso do número grande:
value.fontFamily,value.fontSize,value.bold. - Rótulo da categoria abaixo do número:
label.fontColor,label.fontFamily,label.fontSize. - Preenchimento colorido atrás do número:
cardCalloutArea.backgroundFillColor, com umselectorque nomeia os metadados da projeção da medida — por exemplo,"selector":{"metadata":"Sum(financials.Profit)"}. Leia essa string devisual.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)"}
}]
}
Barra / coluna / linha / donut — cores de dados
dataPoint.defaultColor— a cor de fallback
… (truncado para tradução)