Community研究与数据分析github.com

fahadyousuff/pbix-dashboard-skill

以编程方式在 Power BI Desktop .pbix 文件(PBIR 增强报告格式)中添加或编辑视觉对象、页面和视觉属性,且不会损坏文件。

pbix-dashboard-skill 是什么?

该技能旨在通过编程方式自动化编辑 Power BI Desktop 的 .pbix 文件,支持添加页面、添加或修改视觉对象、更改布局以及重新绑定字段,最终重新打包为 Power BI Desktop 可打开的无损文件。其核心价值在于处理现代 PBIR 增强报告格式的 .pbix 文件,避免常见的文件损坏问题,例如 SecurityBindings 不匹配、BOM 错误、路径无效等。该技能支持 Power BI Desktop 环境,主要采用 PowerShell 和 .NET ZipArchive 进行操作,并提供详细的 JSON 构建指南。工作原理基于解压 .pbix 文件(ZIP 格式),按照指定的 JSON 模式编辑 Report/definition 下的视觉文件,严格遵循 UTF-8 无 BOM 编码,最后使用 .NET 的 ZipArchive 方法重新打包,同时删除或处理 SecurityBindings 文件以确保文件能被正常打开。它还包含验证步骤,检查文件结构、路径和 JSON 完整性。此外,还提供了连接实时 Power BI Desktop 会话以进行数据发现的选项。

兼容平台~Claude Code~Codex CLI~Cursor
npx skills add https://github.com/fahadyousuff/pbix-dashboard-skill/tree/HEAD

Installed? Explore more 研究与数据分析 skills: affaan-m/uspto-database, affaan-m/scholar-evaluation, affaan-m/literature-review · View all 6 →

在你喜欢的 AI 中提问

打开一个已预加载此 Agent Skill 的新对话。

文档

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 中的每个文件。

导致文件损坏的陷阱(从惨痛教训中总结):

  1. Python 的 zipfile 设置了与 .NET 的 ZipArchive 不同的外部文件属性。 Power BI 会验证这些属性。请使用 .NET 的 ZipArchive(通过 PowerShell),每个条目的 ExternalAttributes 都设为 0。
  2. 重新压缩 DataModel 会破坏它。 它必须使用与源文件相同的压缩方法存储(通常是 Deflate,但有时是 Stored);最安全的方法是在你重写时使用 NoCompression,否则原样保留原始字节。
  3. 添加任何文件都会破坏 SecurityBindings。 它是一个已签名的文件内容清单。如果你添加了 Report/definition/pages/<new>/visuals/.../visual.json,签名不再匹配,PBI 将拒绝打开。
  4. 条目的名称不能以 / 开头。 如果在打开文件时遇到 MissingBIPackagePart: /Version 错误,表示你的 Version 条目被压缩成了 /Version(首字母斜杠)。PowerShell 的朴素 Substring($src.Length + 1) 方法会留下一个首字母反斜杠,然后在 ZIP 中变成 /,导致 PBI 拒绝。请根据解析后的长路径计算前缀,并确保每个条目名称都去掉了前缀的 /。
  5. 在 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 无 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 }

    # 编辑 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 — 后备颜色...

相关技能