PBIX Dashboard Patcher (Parcheador de PBIX para Paneles)
Edita un archivo .pbix de Power BI Desktop en su lugar: añade páginas, añade objetos visuales, cambia el diseño, reenlaza campos y vuelve a empaquetarlo para que Power BI Desktop lo abra sin errores de corrupción.
Cuándo usarlo
- El usuario tiene un
.pbixguardado y quiere añadir objetos visuales o generar un panel. - El usuario quiere automatizar las ediciones de informes de Power BI (CI, plantillas masivas, diseño basado en datos).
- Un intento de edición anterior produjo
MashupValidationError,Invalid SecurityBindingso un "archivo corrupto" al abrir.
El problema central (léelo primero)
Los archivos .pbix modernos utilizan el Formato de Informe Mejorado PBIR: el diseño del informe se almacena como archivos JSON por objeto visual en Report/definition/pages/<pageId>/visuals/<visualId>/visual.json, no en el antiguo monolítico Report/Layout.
Un PBIX es un ZIP que contiene (entre otros):
DataModel— el modelo de Analysis Services (NO debe ser re-comprimido; almacenar tal cual).Report/definition/...— árbol JSON PBIR por objeto visual.SecurityBindings— un manifiesto cifrado con DPAPI que firma el diseño del archivo.[Content_Types].xml— lista cada archivo en el ZIP.
Problemas que corrompen el archivo (aprendidos de cicatrices):
- Python
zipfileestablece atributos de archivo externos que difieren deZipArchivede .NET. Power BI los valida. UsaZipArchivede .NET a través de PowerShell, conExternalAttributes = 0en cada entrada. - Re-comprimir
DataModello rompe. Debe almacenarse con el mismo método de compresión que el origen (normalmenteDeflatepero a vecesStored); lo más seguro esNoCompressionsi lo reescribiste, de lo contrario conserva los bytes originales textualmente. - Agregar cualquier archivo rompe
SecurityBindings. Es un manifiesto firmado de lo que hay en el ZIP. Si añadesReport/definition/pages/<nueva>/visuals/.../visual.json, la firma ya no coincide y PBI se niega a abrirlo. - Los nombres de las entradas NO deben tener una
/al inicio.MissingBIPackagePart: /Versional abrir significa que tu entradaVersionfue comprimida como/Version(barra inicial). ElSubstring($src.Length + 1)ingenuo de PowerShell deja una barra invertida inicial → se convierte en/en el zip → PBI lo rechaza. Calcula el prefijo desde la ruta larga resuelta y aplicaTrimStart('/')a cada nombre de entrada. - La marca BOM UTF-8 en
Report/definition/**/*.jsones fatal. Error de PBI:Only text with UTF8 encoding without BOM (byte order marks) is supported. Detected BOM: 'UTF-8'.Set-Content -Encoding UTF8yFile.WriteAllTextsin una codificación explícita AMBOS escriben una BOM en Windows PowerShell 5.1 / .NET Framework. Siempre escribe JSON conNew-Object System.Text.UTF8Encoding($false)pasado aWriteAllTextoStreamWriter.
La solución para SecurityBindings: elimínalo del ZIP y elimina su entrada de [Content_Types].xml. Power BI Desktop regenera SecurityBindings en el siguiente guardado. El archivo se abre limpiamente.
Procedimiento
1. Inspeccionar el archivo
# Extraer a un directorio de trabajo usando .NET para que los atributos vuelvan limpios
Add-Type -AssemblyName System.IO.Compression.FileSystem
[System.IO.Compression.ZipFile]::ExtractToDirectory("Test.pbix", "pbix_src")
Comprueba si es PBIR (nuevo) o heredado:
- PBIR: tiene
Report\definition\pages\<pageId>\visuals\<visualId>\visual.json. - Heredado: solo tiene
Report\Layout(cadena JSON binaria). Esta habilidad se enfoca en PBIR.
2. Entender el esquema visual.json (versión 2.12.0)
Objeto visual cardVisual mínimo (mosaico KPI):
{
"$schema": "https://developer.microsoft.com/json-schemas/fabric/item/report/definition/visualContainer/2.12.0/schema.json",
"name": "<id-hexadecimal-de-20-caracteres>",
"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
}
}
Datos clave:
namees un id hexadecimal de 20 caracteres; debe ser único dentro de la página.- Ejemplos de
visualType:cardVisual,clusteredBarChart,clusteredColumnChart,lineChart,donutChart,pieChart,tableEx,pivotTable. cardVisualusa el rolData. Los gráficos de barras/columnas/líneas usanCategory+Y(+ opcionalSeries). Los gráficos de dona/tarta usanCategory+Y.Aggregation.Function: 0=Suma, 1=Promedio, 2=Conteo, 3=Mínimo, 4=Máximo, 5=ConteoNoNulo, 6=Mediana, 7=DesvEst, 8=Varianza.drillFilterOtherVisuals: truees obligatorio; omitirlo corrompe el archivo.
3. Registrar la página
Report/definition/pages/pages.json:
{"$schema":"...pagesMetadata/1.1.0/schema.json","pageOrder":["<pageId1>","<pageId2>"],"activePageName":"<pageIdX>"}
Cada carpeta de página tiene un page.json:
{"$schema":"...page/2.1.0/schema.json","name":"<pageId>","displayName":"Dashboard","displayOption":"FitToPage","height":1080,"width":1920}
4. Escribir archivos JSON — UTF-8 SIN BOM
Cada vez que edites visual.json, page.json, report.json o cualquier archivo bajo Report/definition/, escríbelo sin BOM. Windows PowerShell 5.1 añade una BOM por defecto.
$utf8NoBom = New-Object System.Text.UTF8Encoding($false)
[System.IO.File]::WriteAllText($ruta, $json, $utf8NoBom)
Para eliminar una BOM ya escrita por un error anterior:
$b = [System.IO.File]::ReadAllBytes($ruta)
if ($b.Length -ge 3 -and $b[0] -eq 0xEF -and $b[1] -eq 0xBB -and $b[2] -eq 0xBF) {
$texto = [System.Text.Encoding]::UTF8.GetString($b, 3, $b.Length - 3)
[System.IO.File]::WriteAllText($ruta, $texto, (New-Object System.Text.UTF8Encoding($false)))
}
5. Reempaquetar — el ÚNICO método fiable
NO uses Python zipfile. Usa PowerShell con .NET ZipArchive. Detalles clave:
- Los nombres de las entradas usan barras inclinadas hacia adelante y NO tienen
/inicial. - Calcula la longitud del prefijo de origen desde
(Get-Item $origen).FullName.TrimEnd('\').Length + 1(misma mayúsculas/minúsculas y forma de ruta larga queFullNameen elementos de Get-ChildItem). AplicaTrimStart('/')a cada nombre de entrada como seguro. - Cada entrada de texto (incluyendo
[Content_Types].xml) se escribe con unStreamWriterUTF-8 sin BOM.
Add-Type -AssemblyName System.IO.Compression
$origen = "pbix_src"
$destino = "Test_patched.pbix"
if (Test-Path $destino) { Remove-Item $destino }
$origenCompleto = (Get-Item $origen).FullName.TrimEnd('\')
$prefijoTam = $origenCompleto.Length + 1
$utf8NoBom = New-Object System.Text.UTF8Encoding($false)
$fs = [System.IO.File]::Open($destino, 'Create')
$zip = New-Object System.IO.Compression.ZipArchive($fs, [System.IO.Compression.ZipArchiveMode]::Create)
Get-ChildItem $origen -Recurse -File | ForEach-Object {
$completo = $_.FullName
$relativo = $completo.Substring($prefijoTam).Replace('\','/').TrimStart('/')
# ELIMINAR SecurityBindings — PBI lo regenera al guardar
if ($relativo -eq 'SecurityBindings') { return }
# Editar Content_Types.xml para eliminar la referencia a SecurityBindings
if ($relativo -eq '[Content_Types].xml') {
$xml = [System.IO.File]::ReadAllText($completo) -replace '<Override[^/]*PartName="/SecurityBindings"[^/]*/>',''
$entrada = $zip.CreateEntry($relativo, [System.IO.Compression.CompressionLevel]::Optimal)
$entrada.ExternalAttributes = 0
$sw = New-Object System.IO.StreamWriter($entrada.Open(), $utf8NoBom)
$sw.Write($xml); $sw.Close()
return
}
# DataModel NO debe ser re-comprimido
$nivel = if ($relativo -eq 'DataModel') {
[System.IO.Compression.CompressionLevel]::NoCompression
} else {
[System.IO.Compression.CompressionLevel]::Optimal
}
$entrada = $zip.CreateEntry($relativo, $nivel)
$entrada.ExternalAttributes = 0 # crítico — Python establece esto a un valor no cero que PBI rechaza
$es = $entrada.Open()
$sfs = [System.IO.File]::OpenRead($completo)
$sfs.CopyTo($es)
$sfs.Close(); $es.Close()
}
$zip.Dispose(); $fs.Close()
6. Validar antes de abrir
Cuatro comprobaciones, en orden:
Add-Type -AssemblyName System.IO.Compression.FileSystem
$z = [System.IO.Compression.ZipFile]::OpenRead("Test_patched.pbix")
# a) Partes requeridas presentes, SecurityBindings ausente
$z.Entries | Where-Object { $_.FullName -match '^(Version|DataModel|Metadata|Settings|SecurityBindings|\[Content_Types\]\.xml)$' } |
Select-Object FullName, Length
# b) Ninguna entrada tiene una '/' inicial
$z.Entries | Where-Object { $_.FullName.StartsWith('/') } | ForEach-Object { "MAL: $($_.FullName)" }
# c) Sin BOM en ningún metadato de texto o JSON de 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 encontrada: $($e.FullName)"
}
}
}
# d) Cada JSON de Report/definition se analiza
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()
Esperado: DataModel, Version, Metadata, Settings, [Content_Types].xml presentes; SecurityBindings ausente; sin barras inclinadas iniciales; sin BOM; todos los JSON se analizan.
Conectarse a una sesión activa de Power BI Desktop (opcional — para descubrimiento de datos)
Si PBI Desktop ya se está ejecutando, expone un punto final de Analysis Services en localhost:
# Descubrir el puerto
Get-Process msmdsrv | ForEach-Object {
$puerto = Get-NetTCPConnection -OwningProcess $_.Id -State Listen |
Where-Object { $_.LocalAddress -eq '127.0.0.1' } |
Select-Object -First 1 -ExpandProperty LocalPort
Write-Host "Puerto de Analysis Services: $puerto"
}
Luego conéctate a través de ADOMD (instala Power BI Desktop; la DLL se incluye con él):
# 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 # espacio de nombres real, no el nombre del archivo
conn = AdomdConnection(f"Data Source=localhost:{puerto}")
conn.Open()
# Ejecutar DAX: EVALUATE SUMMARIZECOLUMNS(financials[Segment], "Sales", SUM(financials[Sales]))
Usa esto solo para leer esquema y previsualizar números; los objetos visuales finales se escriben como archivos JSON y se reempaquetan como se indicó anteriormente.
Dar estilo a los objetos visuales — los nombres de propiedades que realmente funcionan
Las etiquetas del panel de formato en Power BI Desktop NO coinciden con los nombres de propiedades JSON. Si adivinas, PBI elimina silenciosamente las propiedades falsas en el siguiente guardado — el archivo se abre bien, pero tu estilo desaparece. Siempre construye cambios reales en PBI una vez, guarda, extrae y copia los nombres de las propiedades del JSON guardado.
Dónde vive el estilo en visual.json
visual.visualContainerObjects— el marco exterior del mosaico visual (fondo, borde, sombra, barra de título). Se aplica a cada tipo de objeto visual.visual.objects— el interior del objeto visual (área de trazado, ejes, leyenda, puntos de datos, valor de tarjeta, relleno interior de tarjeta). Los nombres de propiedades aquí son por tipo de objeto visual y deben coincidir con el esquemavisualStyles[visualType]del tema.
Marco del contenedor (funciona para cada objeto 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 (mosaico KPI) — problemas comunes
El número grande NO es callout.color y NO es value.color. PBI elimina silenciosamente ambos.
- Color de fuente del número grande:
value.fontColor(nocolor). - Fuente / tamaño / peso del número grande:
value.fontFamily,value.fontSize,value.bold. - Etiqueta de categoría debajo del número:
label.fontColor,label.fontFamily,label.fontSize. - Relleno tintado detrás del número:
cardCalloutArea.backgroundFillColor, con unselectorque nombra los metadatos de la proyección de la medida — p.ej."selector":{"metadata":"Sum(financials.Profit)"}. Lee esa cadena 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)"}
}]
}
Bar / columna / línea / dona — colores de datos
dataPoint.defaultColor— el color de reserva
…
[truncado para la traducción]