パワーポイント内のテキストをgrepする

# はじめに

発表スライドを多数含むディレクトリを前に「あれ？これどこで話したっけ？」「あの話をしたスライドどれだっけ？」と悩むことが多い。そんな時、パワーポイント内のテキストを検索したくなるが、ちょっと検索した範囲ではあまり便利なものがなかったので作った。Mac上で、Mac版PowerPointに対してしか動作確認をしていない。

ファイルは
https://github.com/kaityo256/grep_pptx
においてある。

(2018年9月21日追記) より高速な[D言語版](https://qiita.com/kaityo256/items/ab093cf5534752886ff8)を作ったので、そちらもどうぞ。

# 使い方

[https://github.com/kaityo256/grep_pptx](https://github.com/kaityo256/grep_pptx)のgrep_pptx.rbを使って、探したいディレクトリで

```shell-session
$ ruby grep_pptx.rb keyword
find "keyword" in ./hoge/test2.pptx at slide 5
find "keyword" in ./test.pptx at slide 1
find "keyword" in ./test2.pptx at slide 5
```

とキーワードを指定すると、その場所から再帰的にpptxファイルを探し、そのpptxの何番目のスライドにそのキーワードがあるかを報告する。端末設定にも依ると思われるが、少なくとも筆者の環境では日本語も検索できる。

# 動作原理

## カレントディレクトリ以下のpptxファイルを探す

これは`Dir.glob`を使えば一発。

```rb
Dir.glob './**/*.pptx' do |file|
  find_in_file(file, keyword)
end
```

## unzipとディレクトリ構造

pptxファイルはOffice Open XMLになっており、unzipすれば中身がxmlファイルとして出てくる。

適当なファイルをunzipしてみると、こんなディレクトリ構造になる。

```shell-session
.
├── [Content_Types].xml
├── _rels
├── docProps
│   ├── app.xml
│   ├── core.xml
│   └── thumbnail.jpeg
└── ppt
    ├── _rels
    │   └── presentation.xml.rels
    ├── presProps.xml
    ├── presentation.xml
    ├── slideLayouts
    │   ├── _rels
    │   │   ├── slideLayout1.xml.rels
    │   │   ├── slideLayout10.xml.rels
    │   │   ├── slideLayout11.xml.rels
    │   │   ├── slideLayout2.xml.rels
    │   │   ├── slideLayout3.xml.rels
    │   │   ├── slideLayout4.xml.rels
    │   │   ├── slideLayout5.xml.rels
    │   │   ├── slideLayout6.xml.rels
    │   │   ├── slideLayout7.xml.rels
    │   │   ├── slideLayout8.xml.rels
    │   │   └── slideLayout9.xml.rels
    │   ├── slideLayout1.xml
    │   ├── slideLayout10.xml
    │   ├── slideLayout11.xml
    │   ├── slideLayout2.xml
    │   ├── slideLayout3.xml
    │   ├── slideLayout4.xml
    │   ├── slideLayout5.xml
    │   ├── slideLayout6.xml
    │   ├── slideLayout7.xml
    │   ├── slideLayout8.xml
    │   └── slideLayout9.xml
    ├── slideMasters
    │   ├── _rels
    │   │   └── slideMaster1.xml.rels
    │   └── slideMaster1.xml
    ├── slides
    │   ├── _rels
    │   │   ├── slide1.xml.rels
    │   │   ├── slide2.xml.rels
    │   │   ├── slide3.xml.rels
    │   │   ├── slide4.xml.rels
    │   │   └── slide5.xml.rels
    │   ├── slide1.xml
    │   ├── slide2.xml
    │   ├── slide3.xml
    │   ├── slide4.xml
    │   └── slide5.xml
    ├── tableStyles.xml
    ├── theme
    │   └── theme1.xml
    └── viewProps.xml
```

えらくごちゃごちゃしているんだけど、大事なのは/ppt/slidesにスライドの中身があって、それぞれ`slide1.xml`、`slide2.xml`..となっている。このXMLをパースして、どこにどんなテキストがあるかを調べればよろしい。

とりあえず現在のディレクトリにテンポラリディレクトリを掘って、そこに展開してしまう。真面目にRubyからunzipするのは面倒なので外部コマンドを使ってしまおう。

```rb
def find_in_file(inputfile, keyword)
  Dir.mktmpdir(nil,'./') do |dir|
    `cd #{dir};unzip ../#{inputfile}`
    mygrep(dir, keyword, inputfile)
  end
end
```

あとはこの`mygrep`を実装すればよろしい。

## テキストの探し方

ここで面倒なのが、スライドでは一連のテキストに見えるものが実際には複数のXMLエレメントに分離していることだ。

例えば「平成27年」というテキストを書いてみる。パワーポイント上ではこう見える(見やすいように枠をつけてある)。

![image.png](https://qiita-image-store.s3.amazonaws.com/0/79744/2ec677ee-0b58-7183-1e58-509b5916cd53.png)

これが、対応するXMLではこんな感じにバラされてしまっている。

```xml
<a:p>
  <a:r>
    <a:rPr kumimoji='1' lang='ja-JP' altLang='en-US'/>
    <a:t>
      平成
    </a:t>
  </a:r>
  <a:r>
    <a:rPr kumimoji='1' lang='en-US' altLang='ja-JP'/>
    <a:t>
      27
    </a:t>
  </a:r>
  <a:r>
    <a:rPr kumimoji='1' lang='ja-JP' altLang='en-US'/>
    <a:t>
      年
    </a:t>
  </a:r>
</a:p>
```

このまま検索をかけるのは不便なので、一度XMLに存在するテキストを一括してjoinしてしまう。スライドに対応する`REXML::Document`オブジェクトが`doc`だとすると、

```rb
doc = REXML::Document.new(f.gets(nil))
n =  REXML::XPath.first(doc.root,'/p:sld/p:cSld')
text = REXML::XPath.match(n, './/text()').join
```

とすればスライドに含まれるテキストが全部くっついた文字列が生成されるので、あとは`text.include?(keyword)`で調べれば良い。ファイル名は`slide7.xml`みたいになっているので、スライド番号はそこから取れる。

# ソース

さして長くないのでソース全体も掲載しておく。

```rb
require 'tmpdir'
require 'rexml/document'

if ARGV.size < 1
  puts "usage: ruby grep_pptx.rb keyword"
  exit
end
keyword = ARGV[0]

def mygrep(dir, keyword, inputfile)
  Dir.glob(dir+"/ppt/slides/*.xml") do |file|
    slidenum = 0
    if file=~/slide([0-9]+).xml/
      slidenum = $1.to_i
    end
    f = open(file)
    doc = REXML::Document.new(f.gets(nil))
    n =  REXML::XPath.first(doc.root,'/p:sld/p:cSld')
    text = REXML::XPath.match(n, './/text()').join
    if text.include?(keyword)
      puts "find \"#{keyword}\" in #{inputfile} at slide #{slidenum}"
    end
  end
end

def find_in_file(inputfile, keyword)
  Dir.mktmpdir(nil,'./') do |dir|
    `cd #{dir};unzip ../#{inputfile}`
    mygrep(dir, keyword, inputfile)
  end
end

Dir.glob './**/*.pptx' do |file|
  find_in_file(file, keyword)
end
```

# まとめ

カレントディレクトリ以下にあるpptx全部にgrepをかけて、見つけたファイル名とスライド番号を報告する手抜きスクリプトを作った。問題点や要望としては

* 毎回ファイルをunzipしてはREXMLで解析しているので遅い
* 全部のテキストを結合してから`include?`で探しているので遅い
* 正規表現に対応していない
* 前後のテキストを表示する機能がない
* 標準のgrepと連携したい

などがあるが、とりあえず今の僕の目的には十分なのでよしとする。スクリプトをMITライセンスで置いておくので、好きなように改造してください。

# 参考

* [REXMLで子要素の文字列を取得する](https://techracho.bpsinc.jp/baba/2013_05_29/8828)
* [Wordさんは今日もおつかれです](https://qiita.com/kaityo256/items/c15889dbb7acb2632c6e)