はじめに
PowerPoint プレゼンテーションからテキストを抽出したいときがあります。一般的には Python を使う方法1などが知られています。フリーソフトをインストールするのに会社の情シス部門に申請書を提出する必要があるなど手続きが面倒な人も多いでしょう。なので、新たにアプリをインストールすることなく、Windows に最初からあるツール・ユーティリティだけで実現したいのです。世の中ではそういう人々を 初期装備ニキ と呼んでいるらしいですが・・・
ただし Microsoft Office は必須です。
なお、スクリプトの名前は pptStrings.js とします。UNIX だとバイナリから文字列を抽出する strings というコマンドがあり,そのコマンドにちなみました。
お品書き(仕様案)
- 長い長い PowerPoint ファイル名を指定するのが面倒なので,ハイフン(
-)とすれば現在開いているアクティブプレゼンテーションとします。- デフォルトでは全スライドのテキストを抽出します。オプション
/Sによってスライド番号を指定できます。スライド番号は 1-origin です。 - 複数ページを指定する場合:
/S:1,3,5 - ページ範囲を指定する場合:
/S:1-10
- デフォルトでは全スライドのテキストを抽出します。オプション
- 図形のテキストを抽出します。もちろん表のテキストも抽出できるものとします。
- テキストファイルの文字コードについて,デフォルトは shift_jis としますが, オプション
/Uを指定すると utf-8 とします。 - フォント属性の異なる領域がある場合,デフォルトでは結合して出力しますが,オプション
/Dを指定すると分割して出力します。 - デフォルトでは数式を出力しませんが,オプション
/Mを指定すると出力します。 - 文字列両端(先頭と末尾)のスペースについて,デフォルトは出力しますが,オプション
/Tを指定すると削除します。
| オプション | 意味 | デフォルト |
|---|---|---|
/S:[n] |
Slide number 出力するスライド番号を指定 スライド番号はコンマ区切りで複数指定可能 例) /S:1,3,5ハイフン区切りで範囲指定も可能 例) /S:1-10
|
全ページを出力 |
/U |
UTF-8 format UTF-8で出力 |
shift_jisで出力 |
/D |
Divide string フォント属性の異なる領域を分割して出力 |
結合して出力 |
/M |
Mathzone output 数式を出力 |
数式は出力しない |
/T |
Trim space 文字列両端のスペースを削除 |
スペースを出力 |
実装コード
気の短い人もいるでしょうから先に実装コードを示します。
実装コードはコチラ
//------------------------------------------------------------------------------
// MsoShapeType 列挙
// https://learn.microsoft.com/ja-jp/office/vba/api/office.msoshapetype
//------------------------------------------------------------------------------
var msoGroup = 6; // グループ
//------------------------------------------------------------------------------
// メイン関数の呼び出し
//------------------------------------------------------------------------------
var args = WScript.Arguments.Unnamed;
var opts = WScript.Arguments.Named;
var ret = main(args, opts);
try {
WScript.Quit(ret);
} catch(e) {
/* 何もしない */
}
//------------------------------------------------------------------------------
// メイン関数
//------------------------------------------------------------------------------
function main(args, opts) {
//--------------------------------------------------------------------------
// オプション
//--------------------------------------------------------------------------
var utf8_format = false; // utf-8 形式で出力
var divide_string = false; // フォント属性の異なる文字列を分割
var mathzone_output = false; // 数式ゾーンを出力
var trim_space = false; // 文字列両端のスペースを削除
//--------------------------------------------------------------------------
// ヘルプメッセージの表示
//--------------------------------------------------------------------------
if(args.Count < 2) {
var msg = [
"PowerPoint ファイルの文字列を抽出します。",
"",
"PPTSTRINGS(.JS) (オプション) [PowerPointファイル名] [テキストファイル名]",
"",
"<オプション>",
"/S:[n] スライド番号を指定します。デフォルトは全ページを対象とします。",
" 番号は 1,2,3 とコンマ(,)区切りで複数ページを指定可能です。",
" 番号は 10-20 とハイフン(-)区切りでページ範囲を指定できます。",
"/U utf-8 形式で出力します。デフォルトは shift_jis です。",
"/D フォント属性の異なる文字列を分割して出力します。",
"/M 数式ゾーンを出力します。",
"/T 文字列両端のスペースを削除します。",
"",
"<注意事項>",
"・ファイル名をハイフン(-)とするとアクティブプレゼンテーションを対象とします。"
];
WScript.StdErr.WriteLine(msg.join("\n"));
return -1;
}
//--------------------------------------------------------------------------
// コマンドライン解析
//--------------------------------------------------------------------------
var input_filename = args(0);
var output_filename = args(1);
var pages = [];
if(opts.Exists("S")) {
var a = opts("S").split(",");
for(var i = 0; i < a.length; i++) {
var m;
if((m = a[i].match(/^(\d+)-(\d+)$/)) != null) {
var start = parseInt(m[1]);
var end = parseInt(m[2]);
if(start > end) {
WScript.StdErr.WriteLine("ページ番号の指定 " + a[i] + " が不正です!!");
return -1;
}
for(var n = start; n <= end; n++)
pages.push(n);
} else if((m = a[i].match(/^(\d+)$/)) != null) {
var n = parseInt(m[1]);
pages.push(n);
} else {
WScript.StdErr.WriteLine("ページ番号の指定 " + a[i] + " が不正です!!");
return -1;
}
}
}
if(opts.Exists("U")) utf8_format = true;
if(opts.Exists("D")) divide_string = true;
if(opts.Exists("M")) mathzone_output = true;
if(opts.Exists("T")) trim_space = true;
//--------------------------------------------------------------------------
// PowerPoint の起動
//--------------------------------------------------------------------------
var app;
try {
app = GetObject("", "PowerPoint.Application");
} catch(e) {
app = WScript.CreateObject("PowerPoint.Application");
}
if(app == null) {
WScript.StdErr.WriteLine("PowerPoint の起動に失敗しました!!");
return null;
}
app.Visible = true;
//--------------------------------------------------------------------------
// PowwerPoint ファイルの取得
//--------------------------------------------------------------------------
var ppt = null;
if(input_filename != "-") {
var fso = WScript.CreateObject("Scripting.FileSystemObject");
if(!fso.FileExists(input_filename)) {
WScript.StdErr.WriteLine("ファイル " + input_filename + " が存在しません!!");
return -1;
}
var fullpath1 = fso.GetAbsolutePathName(input_filename).toLowerCase();
for(var i = 1; i <= app.Presentations.Count; i++) {
var fullpath2 = app.Presentations(i).FullName.toLowerCase();
if(fullpath1 == fullpath2) {
ppt = app.Presentations(i);
break;
}
}
if(ppt == null) {
ppt = app.Presentations.Open(fullpath1);
if(ppt == null) {
WScript.StdErr.WriteLine("ファイル " + fullpath1 + " のオープンに失敗しました!!");
return -1;
}
}
} else if(app.Presentations.Count == 0) {
WScript.StdErr.WriteLine("現在オープンしているプレゼンテーションがありません!!");
return -1;
} else if(app.ActivePresentation == null) {
WScript.StdErr.WriteLine("アクティブプレゼンテーションが存在しません!!");
return -1;
} else {
ppt = app.ActivePresentation;
}
//--------------------------------------------------------------------------
// ADODBSTREAM クラスのオープン
//--------------------------------------------------------------------------
var ado = new ADODBSTREAM();
ado.CreateTextFile(output_filename, utf8_format ? "utf-8" : "shift_jis", true);
//--------------------------------------------------------------------------
// テキスト範囲を対象とする
//--------------------------------------------------------------------------
var parse_range = function(range) {
if(!mathzone_output && range.MathZones.Count != 0)
return;
var s = range.Text;
s = s.replace(/\r/g, "");
if(trim_space)
s = s.replace(/^[\s ]+|[\s ]+$/, "");
ado.WriteLine(s);
};
//--------------------------------------------------------------------------
// テキストフレームを対象とする
//--------------------------------------------------------------------------
var parse_frame = function(shape) {
if(!shape.HasTextFrame) return;
if(!shape.TextFrame2.HasText) return;
var range = shape.TextFrame2.TextRange;
if(divide_string) {
for(var i = 1; i <= range.Runs.Count; i++)
parse_range(range.Runs(i));
} else {
parse_range(range);
}
};
//--------------------------------------------------------------------------
// 図形を対象とする
//--------------------------------------------------------------------------
var parse_shape = function(shape) {
if(shape.Type == msoGroup)
for(var i = 1; i <= shape.GroupItems.Count; i++)
parse_shape(shape.GroupItems(i));
if(shape.HasTable)
for(var i = 1; i <= shape.Table.Rows.Count; i++)
for(var j = 1; j <= shape.Table.Columns.Count; j++)
parse_frame(shape.Table.Cell(i, j).Shape);
parse_frame(shape);
}
//--------------------------------------------------------------------------
// スライドを対象とする
//--------------------------------------------------------------------------
var parse_slide = function(slide) {
slide.Select();
for(var i = 1; i <= slide.Shapes.Count; i++)
parse_shape(slide.Shapes(i));
};
//--------------------------------------------------------------------------
// 指定されたページ番号,または全ページを対象とする
//--------------------------------------------------------------------------
if(pages.length == 0) {
for(var i = 1; i <= ppt.Slides.Count; i++) {
parse_slide(ppt.Slides(i));
}
} else {
for(var i = 0; i < pages.length; i++) {
var n = pages[i];
if(n < 1 || n > ppt.Slides.Count) continue;
parse_slide(ppt.Slides(n));
}
}
//--------------------------------------------------------------------------
// ADODBSTREAM クラスのクローズ
//--------------------------------------------------------------------------
ado.Close();
return 0;
}
//------------------------------------------------------------------------------
// ADODB.Stream ラッパークラス
//------------------------------------------------------------------------------
function ADODBSTREAM() {
this.adTypeBinary = 1;
this.adTypeText = 2;
this.adReadAll = -1;
this.adReadLine = -2;
this.adWriteChar = 0;
this.adWriteLine = 1;
this.adSaveCreateNotExist = 1;
this.adSaveCreateOverWrite = 2;
this.stream = WScript.CreateObject("ADODB.Stream");
this.filename = "";
this.overwrite = false;
this.CreateTextFile = function(filename, charset, overwrite) {
this.stream.Type = this.adTypeText;
this.stream.CharSet = charset;
this.stream.Open();
this.filename = filename;
this.overwrite = overwrite;
};
this.WriteLine = function(buf) {
this.stream.WriteText(buf, this.adWriteLine);
};
this.Close = function() {
this.stream.SaveToFile(this.filename,
this.overwrite ? this.adSaveCreateOverWrite : this.adSaveCreateNotExist);
this.stream.Close();
};
}
技術解説
1. オプション解析
オプション /S を解析して,テキストを抽出するスライド番号を配列 pages[] に格納します。配列が空の場合は全スライドを対象とします。ページ範囲を指定した場合,開始番号と終了番号の大小関係に注意してチェック機能をつけました。
var opts = WScript.Arguments.Named;
var pages = [];
if(opts.Exists("S")) {
var a = opts("S").split(",");
for(var i = 0; i < a.length; i++) {
var m;
if((m = a[i].match(/^(\d+)-(\d+)$/)) != null) {
var start = parseInt(m[1]);
var end = parseInt(m[2]);
if(start > end) {
WScript.StdErr.WriteLine("ページ番号の指定 " + a[i] + " が不正です!!");
return -1;
}
for(var n = start; n <= end; n++)
pages.push(n);
} else if((m = a[i].match(/^(\d+)$/)) != null) {
var n = parseInt(m[1]);
pages.push(n);
} else {
WScript.StdErr.WriteLine("ページ番号の指定 " + a[i] + " が不正です!!");
return -1;
}
}
}
2.PowerPoint の起動
PowerPoint を起動します。既に起動している場合は流用します。Visible 属性を true にすることを忘れないようにしましょう。
var app;
try {
app = GetObject("", "PowerPoint.Application");
} catch(e) {
app = WScript.CreateObject("PowerPoint.Application");
}
if(app == null) {
WScript.StdErr.WriteLine("PowerPoint の起動に失敗しました!!");
return null;
}
app.Visible = true;
3. プレゼンテーションのオープン
ファイル名をハイフン(-)とすればアクティブプレゼンテーションとします。アクティブプレゼンテーションが存在しない場合はエラーとしますが,現在オープンしているプレゼンテーションの数 Presentations.Count == 0 で判別することにしました。当初,ActivePresentation == null で検知しようとしたのですが,うまく検知できませんでした。※条件判断は残してあります。
ハイフン(-)ではない場合は絶対パス名を取得します。PowerPoint で現在オープンしているプレゼンテーションファイル名と比較し,絶対パスが一致していればそのプレゼンテーションオブジェクトを取得します。見つからなければ新規オープンします。
var ppt = null;
if(input_filename != "-") {
var fso = WScript.CreateObject("Scripting.FileSystemObject");
if(!fso.FileExists(input_filename)) {
WScript.StdErr.WriteLine("ファイル " + input_filename + " が存在しません!!");
return -1;
}
var fullpath1 = fso.GetAbsolutePathName(input_filename).toLowerCase();
for(var i = 1; i <= app.Presentations.Count; i++) {
var fullpath2 = app.Presentations(i).FullName.toLowerCase();
if(fullpath1 == fullpath2) {
ppt = app.Presentations(i);
break;
}
}
if(ppt == null) {
ppt = app.Presentations.Open(fullpath1);
if(ppt == null) {
WScript.StdErr.WriteLine("ファイル " + fullpath1 + " のオープンに失敗しました!!");
return -1;
}
}
} else if(app.Presentations.Count == 0) {
WScript.StdErr.WriteLine("現在オープンしているプレゼンテーションがありません!!");
return -1;
} else if(app.ActivePresentation == null) {
WScript.StdErr.WriteLine("アクティブプレゼンテーションが存在しません!!");
return -1;
} else {
ppt = app.ActivePresentation;
}
4. プレゼンテーションの処理
配列 pages[] が空の場合は全スライドを対象とします。スライド番号を指定した場合は,配列に格納されたスライド番号に従って処理を進めます。
指定したスライド番号がゼロまたは負の値であったり,全スライド番号(最終ページ番号)よりも大きい場合はエラーとしないでスキップするようにしました。
if(pages.length == 0) {
for(var i = 1; i <= ppt.Slides.Count; i++) {
parse_slide(ppt.Slides(i));
}
} else {
for(var i = 0; i < pages.length; i++) {
var n = pages[i];
if(n < 1 || n > ppt.Slides.Count) continue;
parse_slide(ppt.Slides(n));
}
}
5. スライドの処理
スライド上に存在する全ての図形を対象とします。処理過程が分かるように処理中のスライドをアクティブにします。
var parse_slide = function(slide) {
slide.Select();
for(var i = 1; i <= slide.Shapes.Count; i++)
parse_shape(slide.Shapes(i));
};
6. 図形の処理
グループ化した図形は再帰で処理します。表(テーブル)はセルごとに処理します。
var msoGroup = 6; // グループ
var parse_shape = function(shape) {
if(shape.Type == msoGroup)
for(var i = 1; i <= shape.GroupItems.Count; i++)
parse_shape(shape.GroupItems(i));
if(shape.HasTable)
for(var i = 1; i <= shape.Table.Rows.Count; i++)
for(var j = 1; j <= shape.Table.Columns.Count; j++)
parse_frame(shape.Table.Cell(i, j).Shape);
parse_frame(shape);
}
7. テキストフレームの処理
Microsoft Office のテキストオブジェクトには TextFrame オブジェクトと TextFrame2オブジェクトの二つあります。どちらも TextRange というプロパティを持ちます。TextFrame オブジェクトの TextRange プロパティは文字通り TextRange オブジェクトですが,TextFrame2 オブジェクトの TextRange プロパティは TextRange2 オブジェクトなので要注意です。また TextRange オブジェクトと TextRange2 オブジェクトはどちらも Runs メンバーを持ちます。TextRange.Runs はメソッドですが,TextRange2.Runs はプロパティという違いがあり,両者はよく似ていますが仕様が微妙に異なるので要注意です。
var parse_frame = function(shape) {
if(!shape.HasTextFrame) return;
if(!shape.TextFrame2.HasText) return;
var range = shape.TextFrame2.TextRange;
if(divide_string) {
for(var i = 1; i <= range.Runs.Count; i++)
parse_range(range.Runs(i));
} else {
parse_range(range);
}
};
8. テキスト処理
ようやくテキスト処理に辿り着けました。改行コード(\n)はそのまま通過させますが,キャリッジリターン(\r)は削除します。ADODB.Stream にバイナリ出力するのであれば削除する必要はありませんが,今回はテキストモードで出力するので削除する必要があります。
var parse_range = function(range) {
if(!mathzone_output && range.MathZones.Count != 0)
return;
var s = range.Text;
s = s.replace(/\r/g, "");
if(trim_space)
s = s.replace(/^[\s ]+|[\s ]+$/, "");
ado.WriteLine(s);
};
9. Shift_JIS/UTF-8 出力
Windows Script Host でシステム既定の文字コード(日本語版 Windows だと Shift_JIS)もしくは Unicode(UTF-16lE)以外で出力しようとすると,ADODB.Stream を使う必要があります。今回,ADODB.Stream をそのまま使わず,FileSystemObject のインタフェースに似せた薄いラッパーを作りました。とくに今回はファイル出力だけで良いので簡単です。メソッドは下記の3つしかありません。
CreateTextFile(filename, charset, overwrite)WriteLine(string)Close()
FileSystemObject のメソッドは引数を省略可能ですが,このラッパーは省略できません。
function ADODBSTREAM() {
this.adTypeBinary = 1;
this.adTypeText = 2;
this.adReadAll = -1;
this.adReadLine = -2;
this.adWriteChar = 0;
this.adWriteLine = 1;
this.adSaveCreateNotExist = 1;
this.adSaveCreateOverWrite = 2;
this.stream = WScript.CreateObject("ADODB.Stream");
this.filename = "";
this.overwrite = false;
this.CreateTextFile = function(filename, charset, overwrite) {
this.stream.Type = this.adTypeText;
this.stream.CharSet = charset;
this.stream.Open();
this.filename = filename;
this.overwrite = overwrite;
};
this.WriteLine = function(buf) {
this.stream.WriteText(buf, this.adWriteLine);
};
this.Close = function() {
this.stream.SaveToFile(this.filename,
this.overwrite ? this.adSaveCreateOverWrite : this.adSaveCreateNotExist);
this.stream.Close();
};
}
使い方
1. 引数なしで実行
引数なしで実行するとヘルプメッセージを表示します。
c:\Qiita>pptstrings
PowerPoint ファイルの文字列を抽出します。
PPTSTRINGS(.JS) (オプション) [PowerPointファイル名] [テキストファイル名]
<オプション>
/S:[n] スライド番号を指定します。デフォルトは全ページを対象とします。
番号は 1,2,3 とコンマ(,)区切りで複数ページを指定可能です。
番号は 10-20 とハイフン(-)区切りでページ範囲を指定できます。
/U utf-8 形式で出力します。デフォルトは shift_jis です。
/D フォント属性の異なる文字列を分割して出力します。
/M 数式ゾーンを出力します。
/T 文字列両端のスペースを削除します。
<注意事項>
・ファイル名をハイフン(-)とするとアクティブプレゼンテーションを対象とします。
2. サンプルプレゼンテーションの内容
サンプル sample.pptx のスライド内容です。
それぞれ内訳は下記のようになっています。
- タイトル文字列:スライドマスターでスタイル定義したタイトルです。実態は当該スライド上にあります。
- あいうえお:通常のテキストボックスです。
- $E=mc^2$:通常のテキストボックスに数式を挿入しました。
- 【1】まとめ:通常のテキストボックスです。
- A,B,C,D:表(テーブル)です。
- グループ①,グループ②:それぞれ通常のテキストボックスですが,グループ化しています。
3. デフォルト実行
オプションなしで実行した場合です。
c:\Qiita>pptstrings sample.pptx sample1.txt
数式はデフォルトでは出力されません。
タイトル文字列
あいうえお
【1】まとめ
A
B
C
D
グループ①
グループ②
4. フォント属性の異なる文字列を分割
フォント属性の異なる文字列を分割して出力した場合です。
c:\Qiita>pptstrings sample.pptx sample2.txt /D
まさか【】で区切られるとは予想外でした。
タイトル文字列
あいうえお
【
1
】
まとめ
A
B
C
D
グループ①
グループ②
5. 数式を出力
数式を出力するときは文字コードを UTF-8 にしないと文字化けします。また文字列両端のスペースを削除するようにしました。
c:\Qiita>pptstrings sample.pptx sample3.txt /M /U /T
数式の領域が斜体になっています。これらの文字は数学用英数字記号と呼ばれ,文字コード Unicode は 1D400~1D7FF に割り当てられており,Shift_JIS の領域外です2。
タイトル文字列
あいうえお
𝐸=𝑚 𝑐 2
【1】まとめ
A
B
C
D
グループ①
グループ②
今後の課題
-
本当は抽出したテキストを標準出力に出力したかったのですが,UTF-8 で標準出力に出力する方法が分からなかったので諦めました。不可能かもしれません。
-
指定したスライド番号はソートしたほうが良いかもしれません。
-
テキストを抽出する図形は登録順ですが,位置に応じてソートしたほうが良いかもしれません。
-
ADODB.Stream のラッパーについては引数を省略可能にできるなど,もう少し手を入れたいところです。
-
AI に自作コードを添削させたら SmartArt 対応の追加を提案してきました。
SmartArt対応コードif (shape.HasSmartArt) { var nodes = shape.SmartArt.AllNodes; for (var i = 1; i <= nodes.Count; i++) { var node = nodes(i); if (node.TextFrame2.HasText) parse_range(node.TextFrame2.TextRange); } }また AI は PowerPoint では
msoGroup以外にもmsoCanvasやmsoDiagramが子図形を持つ場合があるとのことで,厳密には下記のように書いたほうが良いと提案してきました。子図形を持つ図形の処理if (shape.Type == msoGroup || shape.Type == msoCanvas || shape.Type == msoDiagram) { for (var i = 1; i <= shape.GroupItems.Count; i++) parse_shape(shape.GroupItems(i)); }これらの図形タイプを自分は扱ったことが無く,今のところ無くても困らないので採用を保留していますが,必要と感じた方は上記のコードを
parse_shapeに追加してください。テスト素材に困るのが最大の理由でしょうか?
最後に
実はこのスクリプトは元々 VBScript で書かれていたものを JavaScript 向けに全面書き換えたものです。JavaScript の言語仕様としては相当古いものですが,それでも正規表現や可変配列が使えるのは便利です。