NTTデータCCSで部門横断での技術支援を担当している riue です。今回、本テックブログの最初の投稿を担当することになりました。よろしくお願いします。
なぜ「加工できる文書」が重要なのか
5 年ほど前の話ですが、私はユーザー企業側の人間として、いわゆるプロダクトオーナー的な位置付けで動いていました。とは言えプロダクトオーナーだからといっても、現実にはプロダクトの全権を握っているわけではありません。プロダクトオーナーの後ろには組織があり、組織の中ではさまざまな人がそれぞれの立場で働いています。当然ながら諸方面に説明し、同意を取り付けて、プロダクトの姿を決める必要があるわけです。
そして要件定義や開発を行う外部パートナー企業から提示される資料は、そのままでは社内説明に使えないことが多々あります。
- 話をスムーズにするために枝葉を排除したり、
- 一般的な用語を社内で理解しやすい用語に修正したり、
- 説明先の部門ごとに記載内容を変えたり…
…なんてことは普通に発生します。
そんなとき、提示された資料が読み取り専用で加工が出来ないと、一から自分で描き起こしたり、スクリーンショットを撮った上で上から別の内容を貼り付けたりと、それはそれは面倒な対応が必要になるわけです。最近は AI で出力した一枚絵をそのまま PowerPoint にぺたりと貼り付けた資料もよく見ますが、当時の自分が受け取ったらかなり困っただろうなと思います。
「読めるだけ」ではなく「書き込める」「加工できる」形式での資料のやり取りは、コミュニケーションを圧倒的に円滑にします。コミュニケーションは自分だけでなく、相手があって初めて成り立つものなのですから。
Markdown ファイルをどのようにしてユーザーにレビューしてもらうか
さて、生成 AI を活用した開発が一般的になるに従い、従来は PowerPoint や Excel 方眼紙で作成されることが一般的だったドキュメント群も、AI で効率的に活用するために Markdown で作成されることが増えてきました。
しかし、特に SI の現場で、これらの Markdown で作成されたドキュメントを顧客にレビューしてもらうことは現実的でしょうか。もちろん、顧客側メンバ個々人のバックグラウンドによっては問題ないかもしれませんが、Markdown を初めて見る方にいきなり Markdown で、というのは現実的には厳しいでしょう。
では PDF や HTML に変換して渡せばいいのかというと、話はそうも簡単ではありません。PDF も HTML も、現実としては「読み取り専用」に近い形式だからです。もちろん適切なツールを使えば PDF に注釈も入れられますし、知識があれば HTML を直接修正することは可能です。しかし、「こう考えているからコメントください」というスタンスに対して、コメントする側がそこまでの費用・労力を負担するというのは本末転倒です。
PDF や HTML のように追記や変更が難しいフォーマットでレビュー依頼が来た場合、レビューする側は指摘箇所と指摘内容をセットで用意する必要があります。典型的にはこんな形式になるでしょうか。
p12
> ユーザーは申請後にメールを受信する
メールだけでなく Teams 通知も送信されるため、記載を修正してください。
p18
> 添付ファイルは暗号化される
暗号化対象は添付ファイルだけでなく、本文データとコメントも含まれます。対象を一覧として明確化してください。
確認結果を受けて次に進む我々には簡単かもしれませんが、確認するほうからするとこれはストレスです。従来、Excel 方眼紙に吹き出しを追加してコメントしていたようなケースではなおさらです。だから Excel 方眼紙が優れているというわけではなく、コメントしたい場所に直接コメントできないのがストレスなのです。
Word をレビュー用の媒体として使う
弊社で現在進めているある開発プロジェクトではこの問題に対応するため、正本である Markdown と、レビュー用の書き込みが容易な媒体を分離するという方法を採用しました。具体的には、Markdown をいったん Word に変換した上で顧客にお渡しし、Word のコメント機能を使ってレビュー指摘を入れてもらっています。
Word をレビュー用の媒体として選んだのは以下の理由からです。
- コメント機能を使えばピンポイントで指摘が入れやすい
- 双方で Microsoft Office を利用しており、扱いやすかった
「Word を挟むのは手間じゃないか」と思われるかもしれませんが、結局のところレビュー用に PDF や HTML に変換するなら手間としては何も変わりません。そして Word をレビュー媒体として使うことで、以下が容易に行えるようになりました。
- 開発側は Markdown を正本としたまま、顧客側では Word でストレスなくレビューを進められる
- 指摘事項を容易に一覧化できるようになった
- 正本である Markdown を修正後も、元のコメントを残したまま再レビューに進めるようになった
作業の流れは以下のようになります。この流れを実現する方法について説明します。
pandoc による Word への変換
Markdown から Word への変換はもちろん AI エージェントにやらせることもできるでしょうが、我々のチームでは OSS (GPL-2.0 ライセンス) の pandoc を利用しています。他のツールと比較してのメリットは以下です。
- インストールが簡単 (単一バイナリ)
- テンプレートファイルを使って Word 文書の見た目を指定できる
- 主要なオプションを共通の設定ファイルに閉じ込められる
pandoc そのものは https://github.com/jgm/pandoc/releases から取得できます。インストーラもありますが、必要なのは実行ファイル pandoc だけなので、Zip 圧縮版でも問題ありません。pandoc を配置したディレクトリにパスを通せば準備完了です。
Markdown から Word への変換そのものは以下で行えます。
pandoc ファイル.md -o ファイル.docx
pandoc がデフォルトで利用する Word のテンプレートもそれほど悪い内容ではないのですが、余白やフォントサイズ、見出しスタイルの目立たせ方など、明らかにカスタマイズしたい部分は多いと思います。では次に、基本的な pandoc の設定に移りましょう。
pandoc のユーザーデータディレクトリ
pandoc は文書の変換の際に参照するデータを、pandoc 自身のユーザーデータディレクトリから取得します。このディレクトリの場所は pandoc -v で User data directory として表示されます。Windows の場合、通常はユーザーディレクトリ直下の AppData/Roaming/pandoc がそれに該当します。
> pandoc -v
pandoc 3.10.1
Features: +server +lua
Scripting engine: Lua 5.4
User data directory: C:\Users\someone\AppData\Roaming\pandoc
:
このディレクトリの直下に reference.docx という名前で Word ファイルを置いておくと、pandoc は自身が持つテンプレートファイルの代わりに、このファイルをテンプレートファイルとして使います。Word の本来のテンプレートファイルは拡張子 .dotx ですが、pandoc の場合は普通の .docx ファイルである点に注意してください。ファイルの中の内容自体は利用されず、使われるのはページ設定 (余白など) とスタイルの定義になります。あとは、自分の好みに合わせて Word のスタイルを修正するだけです。
pandoc 自身が持つテンプレートファイルは
pandoc --print-default-data-file reference.docx > reference.docx
として取り出すことが出来ますが、この reference.docx を直接加工するよりも、一度 pandoc で Markdown から変換した Word ファイルを元にしたほうがよいでしょう。上記で出力した Word ファイルには含まれないスタイル定義、たとえばソースコードブロックに対する段落スタイル定義である Source Code などが含まれていないからです。
また、pandoc はさまざまなコマンドラインオプションを用意していますが、都度これらのコマンドラインオプションを設定せずとも済むよう、デフォルトとして使いたいオプションのセットを単一のファイルに設定することができます。まず、上記のユーザーデータディレクトリの直下に defaults というディレクトリを作成し、その下に例えば word.yaml といった名前で、以下のような YAML ファイルを作成します。
# 個々の見出しに章番号を追加
number-sections: true
# 目次を自動生成
toc: true
# 目次のレベル (見出し 2 までを目次の対象とする)
toc-depth: 2
このように作成したオプションを変換時に適用するには、実行時に設定ファイルの拡張子を除いたファイル名 (上記の例では拡張子 .yaml を除いた word) を、-d オプションによって指示します。
pandoc -d word ファイル.md -o ファイル.docx
これによって生成する Word ファイルには、自動的に見出し 2 レベルまでが含まれた目次が自動的にファイルの先頭に挿入され、かつ各見出しにはたとえば「1.3.2」のような形式で連番が振られます。
コメント一覧の取得
我々のチームではこのように生成した Word ファイルを顧客にお渡しし、顧客には Word のコメント機能を使って指摘事項をコメントとして残してもらっています。Word はあくまでも顧客とのコミュニケーションのために使う媒体であり、正本は Markdown ファイルのままです。ですので、顧客に記載してもらった Word のコメントに基づいて、Markdown 本体に必要な修正を加えることになります。
プロジェクトによっては、レビュー記録を一覧化したり、レビュー実績を定量的に把握したりする必要もあるでしょう。Word のコメントが便利なのは、いつ、誰からのコメントが入ったのかが、コメントの内容と合わせて自動的に記録されるところです。
Word がインストールされている Windows PC であれば、これらの情報は PowerShell 経由で Word を操作することで取得可能です。以下に、これを実現する簡単な PowerShell スクリプトを置いておきます (コードが長いので折りたたんでいます)。
(クリックすると表示されます) Word ファイルからコメントの一覧を取得する PowerShell スクリプト
<#
Word ファイル内のコメントを全て抽出して CSV に出力する
Export-Comments.ps1 .\word.docx .\export.csv
#>
param(
[Parameter(Mandatory = $true, Position = 0)]
[string]$InputWordPath,
[Parameter(Mandatory = $true, Position = 1)]
[string]$OutputCsvPath
)
Set-StrictMode -Version Latest
$ErrorActionPreference = 'Stop'
function Resolve-FullPath {
param(
[Parameter(Mandatory = $true)]
[string]$Path
)
if ([System.IO.Path]::IsPathRooted($Path)) {
return [System.IO.Path]::GetFullPath($Path)
} else {
return [System.IO.Path]::GetFullPath((Join-Path -Path (Get-Location) -ChildPath $Path))
}
}
$inputFullPath = Resolve-FullPath -Path $InputWordPath
$outputFullPath = Resolve-FullPath -Path $OutputCsvPath
if (-not (Test-Path -LiteralPath $inputFullPath)) {
throw "入力ファイルが見つかりません: $inputFullPath"
}
$inputExtension = [System.IO.Path]::GetExtension($inputFullPath)
if ($inputExtension -notin '.docx', '.docm', '.doc') {
throw "入力ファイルは Word 文書である必要があります (.docx / .docm / .doc): $inputFullPath"
}
$outputDir = Split-Path -Parent $outputFullPath
if (-not [string]::IsNullOrWhiteSpace($outputDir) -and -not (Test-Path -LiteralPath $outputDir)) {
New-Item -ItemType Directory -Path $outputDir -Force | Out-Null
}
$word = $null
$doc = $null
try {
$word = New-Object -ComObject Word.Application
$word.Visible = $false
$word.DisplayAlerts = 0
$doc = $word.Documents.Open($inputFullPath, $false, $true)
$results = foreach ($comment in $doc.Comments) {
$commentText = if ($null -ne $comment.Range) {
$comment.Range.Text.Trim()
} else {
''
}
$author = $comment.Author
$scopeRange = $comment.Scope
$selectedText = if ($null -ne $scopeRange) {
$scopeRange.Text.Trim()
} else {
''
}
$paragraphText = ''
$pageNumber = $null
$startOffset = $null
$endOffset = $null
if ($null -ne $scopeRange) {
if ($scopeRange.Paragraphs.Count -ge 1) {
$paragraphText = $scopeRange.Paragraphs(1).Range.Text.Trim()
}
$pageNumber = $scopeRange.Information(3) # wdActiveEndPageNumber
$startOffset = $scopeRange.Start
$endOffset = $scopeRange.End
}
$commentDate = $null
try {
$commentDate = $comment.Date
} catch {
$commentDate = $null
}
[PSCustomObject]@{
# SourceFile = $inputFullPath
Page = $pageNumber
SelectedText = $selectedText
CommentText = $commentText
Author = $author
CommentDate = $commentDate
ParagraphText = $paragraphText
SelectionStart = $startOffset
SelectionEnd = $endOffset
}
}
$results | Export-Csv -Path $outputFullPath -NoTypeInformation -Encoding Shift_JIS
Write-Host "CSV を出力しました: $outputFullPath"
}
finally {
if ($null -ne $doc) {
try {
$doc.Close($false)
} catch {
}
}
if ($null -ne $word) {
try {
$word.Quit()
} catch {
}
try {
[System.Runtime.InteropServices.Marshal]::ReleaseComObject($doc) | Out-Null
} catch {
}
try {
[System.Runtime.InteropServices.Marshal]::ReleaseComObject($word) | Out-Null
} catch {
}
}
[GC]::Collect()
[GC]::WaitForPendingFinalizers()
[GC]::Collect()
[GC]::WaitForPendingFinalizers()
}
文書内のコメントすべてに対して、コメントが付与された場所やコメント本文、コメントをした人やコメントが発生した日時を取り出しています。ここでは Export-Csv を使って CSV に出力していますが、もちろん JSON に出力することも可能です。
コメントを残したまま Markdown の修正を反映する
Markdown が正本なので、Markdown の修正後は再度 Word に変換して顧客に提示する必要があります。ただ、我々のチームでは顧客から「元のコメントは残したままにしておいてほしい」という要望を受けました。
Word の比較機能を利用すると、修正後の文書との差分を元文書へ変更履歴として取り込めます。これは [校閲] タブ > [比較] > [組み込み] で、元の文書に対して変更された文書の内容を反映することで実現できます。この場合、元の文書に記載されたコメントは、そのまま維持されます。
手で実施すると、元文書の指定 / 変更された文書の指定を行った上で、更に反映後の文書を保存して…と少し面倒なので、これも PowerShell で自動化しましょう (長いのでこちらも折りたたんでいます)。
(クリックすると表示されます) Word の比較機能を実行する PowerShell スクリプト Merge-Documents.ps1
<#
二つのドキュメントを Word でマージして、その結果を OutputPath に出力
.\Merge-Documents.ps1 `
"C:\work\before.docx" "C:\work\after.docx" "C:\work\comparison.docx" `
-Granularity Word `
-CompareFormatting $true `
-CompareComments $true `
-CompareMoves $true
#>
param(
[Parameter(Mandatory = $true, Position = 0)]
[string]$OriginalPath,
[Parameter(Mandatory = $true, Position = 1)]
[string]$RevisedPath,
[Parameter(Mandatory = $true, Position = 2)]
[string]$OutputPath,
[ValidateSet("Word", "Character")]
[string]$Granularity = "Word",
[bool]$CompareFormatting = $false,
[bool]$CompareCaseChanges = $true,
[bool]$CompareWhitespace = $false,
[bool]$CompareTables = $true,
[bool]$CompareHeaders = $true,
[bool]$CompareFootnotes = $true,
[bool]$CompareTextboxes = $true,
[bool]$CompareFields = $true,
[bool]$CompareComments = $false,
[bool]$CompareMoves = $true,
[string]$RevisedAuthor = "PowerShell",
[bool]$Visible = $false
)
Set-StrictMode -Version Latest
$ErrorActionPreference = "Stop"
# Word constants
$wdCompareDestinationNew = 0
$wdGranularityCharLevel = 0
$wdGranularityWordLevel = 1
$wdFormatXMLDocument = 12
function Get-FullPath {
param(
[Parameter(Mandatory = $true)]
[string]$Path
)
return $ExecutionContext.SessionState.Path.GetUnresolvedProviderPathFromPSPath($Path)
}
function Get-GranularityValue {
param(
[Parameter(Mandatory = $true)]
[string]$Value
)
switch ($Value) {
"Word" { return $wdGranularityWordLevel }
"Character" { return $wdGranularityCharLevel }
default { throw "Invalid Granularity: $Value" }
}
}
function Release-ComObject {
param(
[Parameter(Mandatory = $false)]
[object]$ComObject
)
if ($null -ne $ComObject) {
try {
[System.Runtime.InteropServices.Marshal]::ReleaseComObject($ComObject) | Out-Null
}
catch {
# Release 失敗は後続処理を止めない
}
}
}
$originalFullPath = (Resolve-Path -LiteralPath $OriginalPath).Path
$revisedFullPath = (Resolve-Path -LiteralPath $RevisedPath).Path
$outputFullPath = Get-FullPath -Path $OutputPath
$outputDirectory = Split-Path -Path $outputFullPath -Parent
if (
-not [string]::IsNullOrWhiteSpace($outputDirectory) `
-and
-not (Test-Path -LiteralPath $outputDirectory)
) {
New-Item -ItemType Directory -Path $outputDirectory -Force | Out-Null
}
# 出力先が比較対象ファイルそのものの場合は、いったん一時ファイルへ保存してから置換する
$replaceTarget = $false
$actualSavePath = $outputFullPath
if (
[string]::Equals($outputFullPath, $originalFullPath, [System.StringComparison]::OrdinalIgnoreCase) `
-or
[string]::Equals($outputFullPath, $revisedFullPath, [System.StringComparison]::OrdinalIgnoreCase)
) {
$replaceTarget = $true
$tempName = [System.IO.Path]::GetFileNameWithoutExtension($outputFullPath) + ".compare.tmp.docx"
$actualSavePath = Join-Path -Path $outputDirectory -ChildPath $tempName
}
$word = $null
$originalDoc = $null
$revisedDoc = $null
$compareDoc = $null
try {
$word = New-Object -ComObject Word.Application
$word.Visible = $Visible
$word.DisplayAlerts = 0
# Open(FileName, ConfirmConversions, ReadOnly, AddToRecentFiles)
$originalDoc = $word.Documents.Open(
$originalFullPath,
$false,
$true,
$false
)
$revisedDoc = $word.Documents.Open(
$revisedFullPath,
$false,
$true,
$false
)
# 比較結果は新規文書に出す
$null = $word.CompareDocuments(
$originalDoc,
$revisedDoc,
$wdCompareDestinationNew,
(Get-GranularityValue -Value $Granularity),
$CompareFormatting,
$CompareCaseChanges,
$CompareWhitespace,
$CompareTables,
$CompareHeaders,
$CompareFootnotes,
$CompareTextboxes,
$CompareFields,
$CompareComments,
$CompareMoves,
$RevisedAuthor,
$true
)
# CompareDocuments の戻り値ではなく、ActiveDocument を掴む方が安定するケースがある
$compareDoc = $word.ActiveDocument
if ($null -eq $compareDoc) {
throw "比較結果の文書を取得できませんでした。"
}
# 既存の一時ファイルがあれば削除
if (Test-Path -LiteralPath $actualSavePath) {
Remove-Item -LiteralPath $actualSavePath -Force
}
$compareDoc.SaveAs2(
$actualSavePath,
$wdFormatXMLDocument)
# SaveAs2 直後に少し待つ (Word COM の切断対策)
Start-Sleep -Milliseconds 500
# 比較対象そのものに保存したい場合は、Word を閉じた後に置換する
if ($replaceTarget) {
$compareDoc.Close($false)
Release-ComObject -ComObject $compareDoc
$compareDoc = $null
$revisedDoc.Close($false)
Release-ComObject -ComObject $revisedDoc
$revisedDoc = $null
$originalDoc.Close($false)
Release-ComObject -ComObject $originalDoc
$originalDoc = $null
$word.Quit()
Release-ComObject -ComObject $word
$word = $null
Start-Sleep -Milliseconds 500
Move-Item -LiteralPath $actualSavePath -Destination $outputFullPath -Force
}
Write-Host "Comparison document saved to: $outputFullPath"
}
finally {
if ($null -ne $compareDoc) {
try { $compareDoc.Close($false) } catch {}
Release-ComObject -ComObject $compareDoc
}
if ($null -ne $revisedDoc) {
try { $revisedDoc.Close($false) } catch {}
Release-ComObject -ComObject $revisedDoc
}
if ($null -ne $originalDoc) {
try { $originalDoc.Close($false) } catch {}
Release-ComObject -ComObject $originalDoc
}
if ($null -ne $word) {
try { $word.Quit() } catch {}
Release-ComObject -ComObject $word
}
[System.GC]::Collect()
[System.GC]::WaitForPendingFinalizers()
}
以下のように実行すれば、
Merge-Documents.ps1 .\before.docx .\after.docx output.docx
「ユーザーからのコメントが入った元文書」「修正後の Markdown から出力した新しい文書」をマージした結果が、第三引数に指定されたファイルに出力されます。
まとめ
本記事では Markdown を正本にしながらも、顧客が扱いやすい形式でレビューを行うための方法を説明しました。pandoc による Word 文書への変換はそれほど手間をかけずに導入できますので、Markdown のレビュー方法に悩んでいる現場では一度試してみてください。
手前味噌ですが、Word のスタイルについて徹底的に解説した書籍を過去に執筆しておりますので、テンプレートファイルのスタイル編集に手間取ったときはお手に取っていただければ幸いです。