OpenPyXLなしでPythonでExcelファイルをいじり倒す(シート追加編)
はじめに
この記事の続きです。Excel の xlsx ファイルの構造はおおむね分かったので、実際に Python で xlsx の中身をいじっていきます。最終的にやりたいのは、ブラウザ上で excel の加工を行うようなアプリケーションで
- ブラウザから excel ファイルと画像をアップロードする
- ユーザ設定に従ってその excel ファイルに新しいシートを追加
- 新しく追加されたシートにはアップロードした画像が指定したレイアウトで埋め込まれる
- 画像と一緒にテキストボックスや矢印が配置されるなどを載せる
という Web アプリケーションです。これを実現しようと思うと
- 既存の excel ファイルをテキストボックスや罫線などを破壊せずに読み込む
- 読み込んだシートに新しいシートを追加できる
- シート上のセルに値を追加できる
- 読み込んだシートにテキストボックスなどの新しい要素を追加できる
- Linux 上で動作する
という機能が少なくとも必要です。これはやはり OpenPyXl や xlwing では難しいです。
なので自作してみます。
今回は 1 と 2 の新シート追加です。
準備:xlsx を扱うための Workbook クラスの定義
まず excel 取り扱うために以下の Workbook クラスを定義します。xml ファイルをきちんとパースしようと思うと膨大なコードを書くことになるので、既存の xml は触らず、そこにデータを追加してくい作戦をとってます。
そのため、OpenPyXL などと違いかなり簡素な作りです。ns とか types のプロパティはなんやねん、と感じると思いますが後で解説するのでスルーしてください。
import shutil
import os
import zipfile
from lxml import etree as ET
import shutil
class Workbook:
def __init__(self, filename):
self.basename = os.path.splitext(os.path.basename(filename))[0]
zip_filename = self.basename+".zip"
shutil.copyfile(filename, zip_filename)
def _rename(info: zipfile.ZipInfo):
LANG_ENC_FLAG = 0x800
encoding = 'utf-8' if info.flag_bits & LANG_ENC_FLAG else 'cp437'
info.filename = info.filename.encode(encoding).decode('cp932')
os.makedirs(self.basename, exist_ok=True)
with zipfile.ZipFile(zip_filename) as zfile:
for info in zfile.infolist():
_rename(info)
zfile.extract(info, self.basename)
self.ns = {
"main": "{http://schemas.openxmlformats.org/spreadsheetml/2006/main}",
"sheet": "{http://schemas.openxmlformats.org/officeDocument/2006/relationships}",
"sheet_rel": "{http://schemas.openxmlformats.org/package/2006/relationships}",
"drawing": "{http://schemas.openxmlformats.org/drawingml/2006/spreadsheetDrawing}",
"types":"{http://schemas.openxmlformats.org/package/2006/content-types}",
"dmain":"{http://schemas.openxmlformats.org/drawingml/2006/main}",
"draw_rel": "{http://schemas.openxmlformats.org/officeDocument/2006/relationships}"
}
self.types = {
"worksheet": "http://schemas.openxmlformats.org/officeDocument/2006/relationships/worksheet",
"image": "http://schemas.openxmlformats.org/officeDocument/2006/relationships/image",
"drawing":"http://schemas.openxmlformats.org/officeDocument/2006/relationships/drawing"
}
self.BASE_DIR = "./pages/img2report/api/assets"
self.templates = {
"sheet": os.path.join(self.BASE_DIR, "new_sheet.xml"),
"sheet_rels": os.path.join(self.BASE_DIR, "new_sheet.xml.rels"),
"shared_strings": os.path.join(self.BASE_DIR, "new_sharedStrings.xml"),
"drawing_rels": os.path.join(self.BASE_DIR, "new_drawing.xml.rels"),
"drawing": os.path.join(self.BASE_DIR, "new_drawing.xml"),
"sheet_rels": os.path.join(self.BASE_DIR, "new_sheet.xml.rels"),
"image": os.path.join(self.BASE_DIR, "new_image.xml"),
"textbox": os.path.join(self.BASE_DIR, "new_textbox.xml"),
"arrow": os.path.join(self.BASE_DIR, "new_arrow.xml")
}
def _write_xml(self, root, filename):
ET.ElementTree(root).write(filename, encoding="UTF-8", xml_declaration=True)
def write_as_xlsx(self, filename=None, delete_wokdir=True):
shutil.make_archive(os.path.splitext(filename)[0], format='zip', root_dir=self.basename)
zipfile = os.path.splitext(filename)[0]+ ".zip"
os.rename(zipfile, filename)
if delete_wokdir:
shutil.rmtree(self.basename)
os.remove(self.basename+".zip")
この Workbook クラスからインスタンスを生成し、write_as_xlsx
メソッドで xlsx ファイルとしての書き出しができます。
wb = Workbook("test.xlsx")
wb.write_xlsx("test_modified.xlsx)
解決方法:xml を加工して新しいシートを追加するメソッド
結論から言うと、この Workbook クラスに以下のメソッドを追加すれば OK です。
def _get_next_relation_id(self, filename):
max_relation_id = 0
tree = ET.parse(filename)
root = tree.getroot()
for child in root:
if int(child.attrib["Id"].lstrip("rId")) > max_relation_id:
max_relation_id = int(child.attrib["Id"].lstrip("rId"))
return f"rId{max_relation_id + 1}"
def add_sheet(self, sheet_name=None):
def _get_next_sheet_id(self):
max_sheet_id = 0
tree = ET.parse(os.path.join(self.basename, "xl", "workbook.xml"))
root = tree.getroot()
for sheets in root.findall(f"{self.ns['main']}sheets"):
for sheet in sheets:
if int(sheet.attrib["sheetId"]) > max_sheet_id:
max_sheet_id = int(sheet.attrib["sheetId"])
return str(max_sheet_id + 1)
def _get_next_sheet_filename(self):
files = os.listdir(os.path.join(self.basename, "xl", "worksheets"))
xml_sheets_nums = [ int(i.lstrip("sheet").rstrip(".xml")) for i in files if i.endswith(".xml")]
next_sheet_num = max(xml_sheets_nums)+1
return f"sheet{next_sheet_num}.xml"
# get params for adding new sheets
next_sheet_id = _get_next_sheet_id(self)
next_relation_id = self._get_next_relation_id(os.path.join(self.basename, "xl", "_rels", "workbook.xml.rels"))
next_sheet_filename = _get_next_sheet_filename(self)
# add new sheet
shutil.copy(self.templates["sheet"], os.path.join(self.basename, "xl", "worksheets", next_sheet_filename))
# edit workbook.xml
tree = ET.parse(os.path.join(self.basename, "xl", "workbook.xml"))
root = tree.getroot()
for sheets in root.findall(f"{self.ns['main']}sheets"):
new_sheet = ET.SubElement(
sheets, f"{self.ns['main']}sheet",
attrib={"name":sheet_name, "sheetId": next_sheet_id, f"{self.ns['sheet']}id": next_relation_id}
)
self._write_xml(root, os.path.join(self.basename, "xl", "workbook.xml"))
# edit workbook.xml.rels
tree = ET.parse(os.path.join(self.basename, "xl", "_rels", "workbook.xml.rels"))
root = tree.getroot()
new_relation = ET.SubElement(
root, f"{self.ns['sheet_rel']}Relationship",
attrib={"Id": next_relation_id, "Type": self.types["worksheet"], "Target": f"worksheets/{next_sheet_filename}"}
)
self._write_xml(root, os.path.join(self.basename, "xl", "_rels", "workbook.xml.rels"))
# update Content Type
tree = ET.parse(os.path.join(self.basename, "[Content_Types].xml"))
root = tree.getroot()
types = ET.SubElement(
root, f"{self.ns['types']}Override",
attrib={
"PartName":f"/xl/worksheets/{next_sheet_filename}",
"ContentType":"application/vnd.openxmlformats-officedocument.spreadsheetml.worksheet+xml"
}
)
self._write_xml(root, os.path.join(self.basename, "[Content_Types].xml"))
return next_sheet_filename
Workbook クラスに対し次のように add_sheet メソッドを実行することでシートが追加されます。
wb = Workbook("test.xlsx")
wb.add_sheet(sheet_name="新しいシート")
wb.write_xlsx("test_modified.xlsx)
解説:add_sheet メソッドの中身
よく知られているように xlsx の実態は zip です。拡張子を変えて解凍すると次のような構造になっています。実際に上の Workbook クラスのコンストラクタでは xlsx の解凍を行っており、作業中にはこれらのファイルが生成されています。
.
├── [Content_Types].xml
├── _rels
├── docProps
│ ├── app.xml
│ └── core.xml
└── xl
├── _rels
│ └── workbook.xml.rels
├── drawings
│ ├── drawing1.xml
│ └── drawing2.xml
├── sharedStrings.xml
├── styles.xml
├── theme
│ └── theme1.xml
├── workbook.xml
└── worksheets
├── _rels
│ ├── sheet1.xml.rels
│ └── sheet2.xml.rels
├── sheet1.xml
└── sheet2.xml
シートの実態はxl/worksheets
以下にあるsheet1.xml
です。つまり、新たに sheet3 を追加したい場合、同じディレクトリにsheet3.xml
を追加すればよい・・・わけではありません。xlsx 内部の xml は相互に参照がなされており、これらの参照関係を解決していく必要があります。この参照関係がおかしくなった xlsx を excel で開こうとすると、
"ファイルが破損しているから修復するね"
的なダイアログが起動時に表示されます。参照関係を解決するには次のファイルを編集し、excel ブックとして認識できるようにする必要があります。
- xl/sheet*.xml
- [Content_Types].xml
- workbook.xml
- _rels/workbook.xml.rels
以下にそれぞれのファイルの編集に関するメモを書いていきます。
sxl/sheet*.xml
コードのこの部分です。予め空っぽのシートに相当する xml を抜き出しておいて、
それをxl/worksheets
にコピーしているだけです。
# add new sheet
shutil.copy(self.templates["sheet"], os.path.join(self.basename, "xl", "worksheets", next_sheet_filename))
[Content_Types].xml
xlsx で扱われる xml ファイルはその xml の種類がそれぞれ定義されています。例えば、workbook.xml
にはContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet.main+xml"
というものが割り当てられています。
先ほど追加したシートにも ContentsType を割り当てる必要があります。
# update Content Type
tree = ET.parse(os.path.join(self.basename, "[Content_Types].xml"))
root = tree.getroot()
types = ET.SubElement(
root, f"{self.ns['types']}Override",
attrib={
"PartName":f"/xl/worksheets/{next_sheet_filename}",
"ContentType":"application/vnd.openxmlformats-officedocument.spreadsheetml.worksheet+xml"
}
)
self._write_xml(root, os.path.join(self.basename, "[Content_Types].xml"))
workbook.xml
excel では複数のシートで構成されるファイル全体のことをブックといいますが、ブックとシートの依存関係を管理しているのがworkbook.xml
です。xl/worksheets
以下にある xml はこのファイルで関連付ける必要があります。なお、workbook.xml
で参照づけられていないシートはファイルとして存在していても excel 上からは認識されなくなります。
# edit workbook.xml
tree = ET.parse(os.path.join(self.basename, "xl", "workbook.xml"))
root = tree.getroot()
for sheets in root.findall(f"{self.ns['main']}sheets"):
new_sheet = ET.SubElement(
sheets, f"{self.ns['main']}sheet",
attrib={"name":sheet_name, "sheetId": next_sheet_id, f"{self.ns['sheet']}id": next_relation_id}
)
self._write_xml(root, os.path.join(self.basename, "xl", "workbook.xml"))
_rels/workbook.xml.rels
workbook.xml
ではブックに紐づくシートを ID で参照しています。workbook.xml.rels
ではその ID のファイルへのリンクが記載されているので、ここも修正します。これで終わりです。excel ファイルが破損した状態はいろいろな理由がありますが、その一つに.rels に記載しているファイルが存在しないというものがあります。
# edit workbook.xml.rels
tree = ET.parse(os.path.join(self.basename, "xl", "_rels", "workbook.xml.rels"))
root = tree.getroot()
new_relation = ET.SubElement(
root, f"{self.ns['sheet_rel']}Relationship",
attrib={"Id": next_relation_id, "Type": self.types["worksheet"], "Target": f"worksheets/{next_sheet_filename}"}
)
self._write_xml(root, os.path.join(self.basename, "xl", "_rels", "workbook.xml.rels"))
終わりに
どうでしょうか。xml の中身を見てなんとかなるやろと思ってましたが想像以上に大変でした・・・。次は追加したシートに値を追加するメソッドについて書いていきます。
Discussion