param( [Parameter(Mandatory = $true)] [string]$Source, [string]$Output = (Join-Path $PSScriptRoot '..\artifacts\requirements-source-inventory.md') ) $ErrorActionPreference = 'Stop' Add-Type -AssemblyName System.IO.Compression.FileSystem $sourcePath = (Resolve-Path -LiteralPath $Source).Path $outputPath = [System.IO.Path]::GetFullPath($Output) $outputDirectory = Split-Path -Parent $outputPath if (-not (Test-Path -LiteralPath $outputDirectory)) { New-Item -ItemType Directory -Path $outputDirectory | Out-Null } $stylesByID = @{} $archive = [System.IO.Compression.ZipFile]::OpenRead($sourcePath) try { $entry = $archive.GetEntry('word/document.xml') if ($null -eq $entry) { throw 'DOCX中缺少word/document.xml' } $stream = $entry.Open() try { $reader = [System.IO.StreamReader]::new($stream, [System.Text.Encoding]::UTF8, $true) try { [xml]$document = $reader.ReadToEnd() } finally { $reader.Dispose() } } finally { $stream.Dispose() } $stylesEntry = $archive.GetEntry('word/styles.xml') if ($null -ne $stylesEntry) { $stylesStream = $stylesEntry.Open() try { $stylesReader = [System.IO.StreamReader]::new($stylesStream, [System.Text.Encoding]::UTF8, $true) try { [xml]$stylesDocument = $stylesReader.ReadToEnd() } finally { $stylesReader.Dispose() } } finally { $stylesStream.Dispose() } $stylesNamespace = [System.Xml.XmlNamespaceManager]::new($stylesDocument.NameTable) $stylesNamespace.AddNamespace('w', 'http://schemas.openxmlformats.org/wordprocessingml/2006/main') foreach ($styleNode in $stylesDocument.SelectNodes('/w:styles/w:style', $stylesNamespace)) { $idAttribute = $styleNode.Attributes.GetNamedItem('styleId', 'http://schemas.openxmlformats.org/wordprocessingml/2006/main') $nameNode = $styleNode.SelectSingleNode('./w:name', $stylesNamespace) if ($null -eq $idAttribute -or $null -eq $nameNode) { continue } $nameAttribute = $nameNode.Attributes.GetNamedItem('val', 'http://schemas.openxmlformats.org/wordprocessingml/2006/main') if ($null -ne $nameAttribute) { $stylesByID[$idAttribute.Value] = $nameAttribute.Value } } } } finally { $archive.Dispose() } $namespace = [System.Xml.XmlNamespaceManager]::new($document.NameTable) $namespace.AddNamespace('w', 'http://schemas.openxmlformats.org/wordprocessingml/2006/main') $paragraphs = $document.SelectNodes('/w:document/w:body/w:p', $namespace) if ($paragraphs.Count -eq 0) { throw 'DOCX正文没有可追踪段落' } $hash = (Get-FileHash -LiteralPath $sourcePath -Algorithm SHA256).Hash.ToLowerInvariant() $lines = [System.Collections.Generic.List[string]]::new() $lines.Add('# 原始需求来源段落清单') $lines.Add('') $lines.Add('> 此文件由`scripts/extract-requirements.ps1`从原始DOCX机械提取,用于稳定引用来源,不替代原需求文档。`Pxxxx`是`word/document.xml`正文段落的零基顺序号;空段落保留编号但不输出正文。') $lines.Add('') $lines.Add("- 来源文件:$([System.IO.Path]::GetFileName($sourcePath))") $lines.Add(('- SHA-256:`{0}`' -f $hash)) $lines.Add("- 正文段落总数:$($paragraphs.Count)") $lines.Add('') $lines.Add('## 非空段落') $lines.Add('') $nonEmpty = 0 for ($index = 0; $index -lt $paragraphs.Count; $index++) { $paragraph = $paragraphs[$index] $textParts = $paragraph.SelectNodes('.//w:t', $namespace) | ForEach-Object { $_.InnerText } $text = (($textParts -join '') -replace '[\r\n\t]+', ' ' -replace '\s{2,}', ' ').Trim() if ([string]::IsNullOrWhiteSpace($text)) { continue } $nonEmpty++ $styleNode = $paragraph.SelectSingleNode('./w:pPr/w:pStyle', $namespace) $style = 'Normal' if ($null -ne $styleNode) { $styleAttribute = $styleNode.Attributes.GetNamedItem('val', 'http://schemas.openxmlformats.org/wordprocessingml/2006/main') if ($null -ne $styleAttribute -and -not [string]::IsNullOrWhiteSpace($styleAttribute.Value)) { $style = $styleAttribute.Value if ($stylesByID.ContainsKey($style)) { $style = $stylesByID[$style] } } } $safeText = $text.Replace('\', '\\').Replace('`', '\`') $lines.Add(('- `P{0}` [{1}] {2}' -f $index.ToString('0000'), $style, $safeText)) } $lines.Insert(7, "- 非空段落数:$nonEmpty") [System.IO.File]::WriteAllLines($outputPath, $lines, [System.Text.UTF8Encoding]::new($false)) Write-Output "已提取 $nonEmpty/$($paragraphs.Count) 个段落:$outputPath"