#//	
#//	DumpXlsx.tcl - xlsx/xlsmファイルのシートデータをダンプするTclスクリプト
#//	
#//	* Thu Jun 30 21:54:58 JST 2016 Naoyuki Sawa
#//	- 1st リリース。
#//	  DumpXlsx.bat経由で使用して下さい。
#//	
package require tdom
package require vfs::zip
#------------------------------------------------------------------------------
#//グローバル変数
proc initGlobal {} {
useGlobal
set	VERSION			"20160630"		;#//最終更新日
set	g_zipfd			""
set	g_sharedStrings		[list]
set	g_sheetNameToId		[dict create]
set	g_sheetIdToFileName	[dict create]
}
#- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - 
proc useGlobal {} {
uplevel {
global	VERSION
global	g_zipfd
global	g_sharedStrings
global	g_sheetNameToId
global	g_sheetIdToFileName
}}
#- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - 
initGlobal
#------------------------------------------------------------------------------
#//ZIPファイルを、仮想ファイルシステムにマウントする。
proc mountZip {zipFileName} {
	useGlobal
	#//ZIPファイルを、仮想的な"zip"フォルダにマウントする。
	set g_zipfd [vfs::zip::Mount $zipFileName "zip"]
}
#------------------------------------------------------------------------------
#//ZIPファイルを、仮想ファイルシステムからマウント解除する。
proc unmountZip {} {
	useGlobal
	#//ZIPファイルを、仮想的な"zip"フォルダからマウント解除する。
	vfs::zip::Unmount $g_zipfd "zip"
}
#------------------------------------------------------------------------------
#//仮想ファイルシステムから、XMLファイルを読み込み、ドキュメントオブジェクトを返す。
proc loadXml {xmlFileName} {
	useGlobal
	#//XMLファイル名に、仮想ファイルシテスムのパスを追加する。
	set xmlFileName [file join "zip" $xmlFileName]
	#//XMLファイルに指定されたエンコーディングを使って、XMLファイルを開く。
	set fd [tDOM::xmlOpenFile $xmlFileName]
	#//XMLファイルを読み込み、ドキュメントオブジェクトを作成する。
	set doc [dom parse -channel $fd]
	#//XMLファイルを閉じる。
	close $fd
	#//ドキュメントオブジェクトを返す。
	return $doc
}
#------------------------------------------------------------------------------
#//共有文字列テーブルを読み込む。
proc loadSharedStrings {} {
	useGlobal
	#//共有文字列テーブルのXMLファイル名
	set xmlFileName [file join "xl" "sharedStrings.xml"]
	#//共有文字列テーブルのXMLファイルが有れば…
	#// - 文字列を一切使用していないエクセルファイルの場合は、共有文字列テーブルのXMLファイルが含まれていません。
	if {[file exists [file join "zip" $xmlFileName]]} then {
		#//XMLファイルを読み込み、ドキュメントオブジェクトを取得する。
		set doc [loadXml $xmlFileName]
		#//ルートノード<sst>について…
		set sst [$doc selectNodes {*[local-name()='sst']}]
		if {[llength $sst] != 1} then {DIE}
		#//子ノード<si>について…
		foreach si [$sst selectNodes {*[local-name()='si']}] {
			#//文字列を取得する。
			set s [loadSharedStrings_subr $si ""]
			#//文字列をリストに追加する。
			lappend g_sharedStrings $s
		}
		#//ドキュメントオブジェクトを解放する。
		$doc delete
	}
}
#- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - 
proc loadSharedStrings_subr {node s} {
	useGlobal
	#//読み仮名のノードは除外する。
	if {[$node localName] ne "rPh"} then {
		#//このノードが<t>ならば…
		if {[$node localName] eq "t"} then {
			#//文字列を追加する。
			append s [$node text]
		}
		#//各子ノードについて…
		foreach i [$node selectNodes {*}] {
			#//再起処理を行う。
			set s [loadSharedStrings_subr $i $s]
		}
	}
	return $s
}
#------------------------------------------------------------------------------
#//(シート名⇒シートID)辞書を読み込む。
proc loadSheetNameToId {} {
	useGlobal
	#//ワークブックのXMLファイル名
	set xmlFileName [file join "xl" "workbook.xml"]
	#//XMLファイルを読み込み、ドキュメントオブジェクトを取得する。
	set doc [loadXml $xmlFileName]
	#//ルートノード<workbook>について…
	set workbook [$doc selectNodes {*[local-name()='workbook']}]
	if {[llength $workbook] != 1} then {DIE}
	#//子ノード<sheets>について…
	set sheets [$workbook selectNodes {*[local-name()='sheets']}]
	if {[llength $sheets] != 1} then {DIE}
	#//子ノード<sheet>について…
	foreach sheet [$sheets selectNodes {*[local-name()='sheet']}] {
		#//id属性を取得する。
		set id [$sheet getAttribute "r:id"]	;#//※TODO:一応これでも取得出来るが気持ち悪い。ネームスペースを無視する書き方は無いのか?(要検討)
		#//name属性を取得する。
		set name [$sheet getAttribute "name"]
		#//(シート名⇒シートID)を登録する。
		dict set g_sheetNameToId $name $id
	}
	#//ドキュメントオブジェクトを解放する。
	$doc delete
}
#------------------------------------------------------------------------------
#//(シートID⇒シートファイル名)辞書を読み込む。
proc loadSheetIdToFileName {} {
	useGlobal
	#//ワークブックリレーションのXMLファイル名
	set xmlFileName [file join "xl" "_rels" "workbook.xml.rels"]
	#//XMLファイルを読み込み、ドキュメントオブジェクトを取得する。
	set doc [loadXml $xmlFileName]
	#//ルートノード<Relationships>について…
	set Relationships [$doc selectNodes {*[local-name()='Relationships']}]
	if {[llength $Relationships] != 1} then {DIE}
	#//子ノード<Relationship>について…
	foreach Relationship [$Relationships selectNodes {*[local-name()='Relationship']}] {
		#//Id属性を取得する。
		set Id [$Relationship getAttribute "Id"]
		#//Target属性を取得する。
		set Target [$Relationship getAttribute "Target"]
		#//(シートID⇒シートファイル名)を登録する。
		dict set g_sheetIdToFileName $Id $Target
	}
	#//ドキュメントオブジェクトを解放する。
	$doc delete
}
#------------------------------------------------------------------------------
#//シートデータを読み込む。
proc loadSheetData {sheetFileName} {
	useGlobal
	#//シートデータの辞書を作成する。
	set d [dict create]
	#//シートファイル名に、暗黙の"xl"のパスを追加する。
	set sheetFileName [file join "xl" $sheetFileName]
	#//XMLファイルを読み込み、ドキュメントオブジェクトを取得する。
	set doc [loadXml $sheetFileName]
	#//ルートノード<worksheet>について…
	set worksheet [$doc selectNodes {*[local-name()='worksheet']}]
	if {[llength $worksheet] != 1} then {DIE}
	#//子ノード<sheetData>について…
	set sheetData [$worksheet selectNodes {*[local-name()='sheetData']}]
	if {[llength $sheetData] != 1} then {DIE}
	#//子ノード<row>について…
	foreach row [$sheetData selectNodes {*[local-name()='row']}] {
		#//子ノード<c>について…
		foreach c [$row selectNodes {*[local-name()='c']}] {
			#//r属性を取得する。
			set r [$c getAttribute "r"]
			#//t属性を取得する。t属性が無ければ""とする。
			set t [$c getAttribute "t" ""]
			#//子ノード<v>が有れば…
			#// - 色だけを塗って何も入力されていないセル等の場合、子ノード<v>が無い。
			#//   そのようなセルを登録しても無駄なので、子ノード<v>が無いセルは無視する事にした。
			set v [$c selectNodes {*[local-name()='v']}]
			if {$v ne ""} then {
				if {[llength $v] != 1} then {DIE}
				#//値を取得する。
				set v [$v text]
				#//値が空でなければ…
				#// - 文字列式が設定されており、その結果が空文字になるセル等の場合、<v/>となる。
				#//   そのようなセルを登録しても無駄なので、値が空であるセルは無視する事にした。
				if {$v ne ""} then {
					#//t属性の値が"s"だったならば…
					if {$t eq "s"} then {
						#//共有文字列テーブルから値を取得する。
						if {$v >= [llength $g_sharedStrings]} then {DIE}
						set v [lindex $g_sharedStrings $v]
					}
					#//値に制御文字が含まれていない事を確認する。
					if {[regexp {[[:cntrl:]]} $v]} then {DIE}	;#//制御文字(改行やタブも含む)は使用不可。元のエクセルファイルを修正して下さい。
					#//値を登録する。
					dict set d $r $v
				}
			}
		}
	}
	#//ドキュメントオブジェクトを解放する。
	$doc delete
	#//シートデータの辞書を返す。
	return $d
}
#------------------------------------------------------------------------------
#//シートデータを出力する。
proc doSave {sheetData xlsxFileName sheetName outDir} {
	#//シート名に拡張子".txt"を追加して、出力ファイルとする。
	set fileName "$sheetName.txt"
	#//出力フォルダが指定されていたら、パスを追加する。
	if {$outDir ne ""} then {
		set fileName [file join $outDir $fileName]
	}
	#//出力ファイルを作成する。
	set fd [open $fileName "w"]
	#//エクセルファイル名を、パスとファイル名に分割する。
;#{{不要かも?要検討
	set xlsxFileName [file normalize $xlsxFileName]			;#//エクセルファイル名を、フルパス名に変換する。
;#}}不要かも?要検討
	set bookName [file tail    $xlsxFileName]			;#//ファイル名の部分を取得する。
	set pathName [file dirname $xlsxFileName]			;#//パス名の部分を取得する。														┬この順序必須。逆にすると'file dirname'によって「\」⇒「/」に戻されてしまう。
	set pathName [file nativename $pathName]			;#//エクセルの参照式のパスセパレータは、「/」でも「\」でも大丈夫なようだが、「\」の方がより確実なので、「\」に揃えておく事にした。	┘
	if {$pathName ne ""} then {					;#//パス名が空でなければ…(無いとは思うが、万一、「\」を追加して「\」だけになってしまう危険を避けるためです。)
		if {[string index $pathName end] ne "\\"} then {	;#//パス名の最後の文字が「\」でなければ…(Tclの'file dirname'はsplitpath()と違って、ディレクトリ名の最後のパスセパレータを除去してしまうようなので、多分そうなるでしょう。)
			append pathName "\\"				;#//パス名の最後にパスセパレータを補う。エクセルの参照表現「'パス名\[ブック名]シート名'!セル参照」のために必須である。
		}
	}
	#//シートデータを出力する。
	dict for {r v} $sheetData {
		set r [format {'%s[%s]%s'!%s} $pathName $bookName $sheetName $r]	;#//「'パス名\[ブック名]シート名'!セル参照」(clipxls.c参照)
		puts $fd "$r\t$v"
	}
	#//出力ファイルを閉じる。
	close $fd
}
#------------------------------------------------------------------------------
#//使い方を表示して終了する。
proc usage {} {
	useGlobal
	puts stderr "DumpXlsx.tcl - xlsx/xlsmファイルのシートデータをダンプするTclスクリプト ($VERSION)"
	puts stderr {Copyright (C) 2016 Naoyuki Sawa}
	puts stderr {}
	puts stderr {USAGE:}
	puts stderr {  tclsh.exe DumpXlsx.tcl エクセルファイル名 [出力フォルダ名]}
	puts stderr {  ・出力フォルダ名を省略すると、"out"フォルダに出力します。}
	puts stderr {}
	puts stderr {EXAMPLE:}
	puts stderr {  tclsh.exe DumpXlsx.tcl Sample.xlsx out}
	puts stderr {  Sample.xlsxを読み込んで、全てのシートのシートデータを、outフォルダに出力します。}
	exit 1
}
#------------------------------------------------------------------------------
#//メインルーチン
proc main {argv} {
	useGlobal
	#//コマンドラインで指定された、エクセルファイル名を取得する。
	set xlsxFileName [lindex $argv 0]
	if {$xlsxFileName eq ""} then {usage}		;#//エクセルファイル名は省略不可です。
	#//コマンドラインで指定された、出力フォルダ名を取得する。
	set outDir [lindex $argv 1]
	if {$outDir eq ""} then {set outDir "out"}	;#//出力フォルダ名が省略されたら"out"とする。
	;#//出力フォルダを確実に作成する。
	file mkdir $outDir				;#//作成しようとするフォルダが既に存在していても、'file mkdir'はエラーにならないので安全です。
	#//エクセルファイルを、ZIPファイルとして仮想ファイルシステムにマウントする。
	mountZip $xlsxFileName
	#//共有文字列テーブルを読み込む。
	loadSharedStrings
	#//(シート名⇒シートID)辞書を読み込む。
	loadSheetNameToId
	#//(シートID⇒シートファイル名)辞書を読み込む。
	loadSheetIdToFileName
	#//各(シート名,シートID)について…
	dict for {sheetName sheetId} $g_sheetNameToId {
		puts stderr $sheetName
		#//シートファイル名を取得する。
		set fileName [dict get $g_sheetIdToFileName $sheetId]
		#//シートデータを読み込む。
		set sheetData [loadSheetData $fileName]
		#//シートデータを出力する。
		doSave $sheetData $xlsxFileName $sheetName $outDir
	}
	#//ZIPファイルを、仮想ファイルシステムからマウント解除する。
	unmountZip
}
main $argv
#------------------------------------------------------------------------------
