Word to Markdown

person holding white Samsung Galaxy Tab

システムマニュアルや議事録など、多くの文書が Word か Word から出力した pdf を利用しています。Obsidian や Logseq では、pdf の一部を参照したリンクを張ることができますが、一部ではなく全体を対象とした検索を行いたいケースも多々あります。ここでは、Word から Markdown に変更するプログラムについて提供および解説します。

概要

  • Pandoc を利用して様々な形式のファイルを Markdown 形式に変換
  • python で以下の処理を実行 (標準モジュールを利用)

処理概要

  1. 実行フォルダにあるすべてのdocxを対象にして、Pandoc で Markdown に変換
  2. 変換結果の先頭にプロパティを追加 (Logseq のみ)
  3. 目次やイメージファイルなどを修正

制限事項

  • Word 文書も対象なのですが、docxに制限されます。
  • pdf 出力はできるのですが、残念ながら入力として扱うことができません。
    • pdf を docx に変換できる方法は色々ありますので、別途変換してください。
  • Pandoc の結果をある程度整形していますが、完全な再現性までは行っていません。
    • あくまでも内容を確認するのが目的であり、分かり辛い場合は原文を確認し、必要に応じて Markdown を修正してください。

免責事項

  • 十分なテストを行っていますが、環境などにより不具合が生じる可能性があります。
  • 本プログラムの実行により、不利益を生じた場合でも、いかなる責任も負いません。自己責任で実施してください。

Obsidian 用

使い方

  • ソースをコピーして、word2ob.py という名前で保存 (名前を変更した場合、以下のword2ob.pyを読み替え)
  • 変換したい Word 文書があるフォルダで python3 word2ob.py を実行
  • Markdown と画像フォルダを含む md フォルダを Obsidian の任意のフォルダにコピー

ソース

#!/usr/bin/python3

import glob
import re
import subprocess
import sys
import datetime

mdfiles = glob.glob("*.md")
inch2pixel = 144

res = subprocess.call(["mkdir", 'md'], stdout=subprocess.PIPE)
res = subprocess.call(["mkdir", 'md/media'], stdout=subprocess.PIPE)
res = subprocess.call(["mkdir", 'tmp'], stdout=subprocess.PIPE)

for filename in glob.glob("*.docx"):
	mdname = re.sub(r'(.*)\.docx',r'\1.md',filename)
	flname = re.sub(r'(.*)\.docx',r'\1',filename) + " - " + datetime.datetime.now().strftime("%Y%m%d-%H%M%S")
	res = subprocess.call(["mkdir", flname], stdout=subprocess.PIPE)
		
	if mdname in mdfiles:
		print ("Exists: " + filename)
	else:
		print("converting: " + filename)
		res = subprocess.call([
			"pandoc", filename,
			"--wrap=none",
			"--extract-media=" + flname,
			"-t","gfm",
			"-o", mdname], stdout=subprocess.PIPE) 
		res = subprocess.call(["mv", flname, "md/media/"], stdout=subprocess.PIPE)
		with open(mdname) as mdfile:
			with open("tmp/" + mdname, mode='w') as newfile:
				newfile.writelines("# ")
				findingHeader = 0
				startFinding = 0
				for line in mdfile:
					tmpLine = line #.strip()
					if re.search(r'<span id=.+</span>.+', line): # headings
						tmpLine = re.sub(r'<span id=\".*\" class=\"anchor\"></span>(.+)\s*$', r'## \1',line)
					elif re.search(r'^\s*\[(.+)\s\d+\]\(#.+\)\s*$', line): # toc
						tmpLine = re.sub(r'^\s*\[(.+) \d+\]\(#.+\)\s*$', r'- [[#\1]]', line) # obsidian enhanced link to header in same file
					elif re.search(r'<span id=.+</span>$', line): # heading without sentences, mark and replace later
						tmpLine = re.sub(r'<span id=\".*\" class=\"anchor\"></span>$', r'\n\n## [[FIX HERE]]',line)
					# replace image which may happen several time with adjusting size
					if re.search(r'<img src=.+?(png|jpg|jpeg|gif|emf).+? />', tmpLine):
						result = re.findall(r'(<img src=\"(.+?\.)(png|jpg|jpeg|gif|emf)\" .*width:([.\d]+)in.+? />)', line)
						for m in result:							
							width = str(int(float(m[3]) * inch2pixel))
							tmpLine = tmpLine.replace(m[0], ' ![[' + m[1] + m[2] + '|' + width + ']] ')
					# write to file
					newfile.write(tmpLine)

		srcFile = open("tmp/" + mdname)
		srcText = srcFile.read()
		srcFile.close()
		dstText = re.subn(r'^## \[\[FIX HERE\]\][\s\*]+(\S)', r'## \1', srcText, 0, re.MULTILINE)[0] # find next meaningful sentence
		dstText = re.subn(r'(</?tr.*?>)', r'\n \1', dstText)[0] # easy reading
		dstText = re.subn(r'(<td.*?</td>)', r'\n  \1', dstText)[0] # easy reading
		dstText = re.subn(r'(<th.*?</th>)', r'\n  \1', dstText)[0] # easy reading
		dstText = re.subn(r'<blockquote><p>(.*?)</p></blockquote>', r'\1', dstText)[0] # some cell in table has this
		dstFile = open("md/" + mdname, mode='w')
		dstFile.write(dstText)
		dstFile.close()
						
		res = subprocess.call(["rm", mdname], stdout=subprocess.PIPE)
		
res = subprocess.call(["rm", '-rf', './tmp'], stdout=subprocess.PIPE)
		

Logseq 用

どの単位でブロックにまとめるべきか、あるいはできるか考え中です。Logseq の検索で大きなブロックがHitした場合に、どの部分が該当しているのか分かり辛いこともあるので、適切に分割することが必要です。

また、ブロック単位での zettelkasten 方式で記録するようにしているので、可能な限り実態情報は journal に持たせようとしているので、今の使い方は、Obsidian と併用して、Obsidian 用に変換した結果を保持し、必要時には Obsidian で検索して、検索結果を参考にしながら、Logseq 側では pdf への参照を入れるようにしています。

まだ自分で実用しているわけではありませんが、ある程度の変更を加えたので参考に共有します。

準備

目次作成プラグイン TOC Generator を利用していますので、本プラグインをインストールしてください。

使い方

  • ソースをコピーして、word2lg.py という名前で保存 (名前を変更した場合、以下のword2lg.pyを読み替え)
  • 変換したい Word 文書があるフォルダで python3 word2lg.py を実行
  • pages フォルダの Markdown ファイルを pages にコピー
  • assets フォルダにある画像フォルダを logseq/assets にコピー

ソース

#!/usr/bin/python3

## https://www.takizui.com/
## ver 1.00 released on 2022-06-12

import glob
import re
import subprocess
import sys
import datetime

mdfiles = glob.glob("*.md")
# media = sys.argv[1]
# mediaSeq = 1
inch2pixel = 144

## set timestamp type via argument
tstype = 't' # default - add datetime as postfix
if len(sys.argv) > 1:
	if re.search(r'ts', sys.argv[1]) or re.search(r'timestamp', sys.argv[1]):
		if re.search(r'date', sys.argv[1]):
			tstype = 'd'
		elif re.search(r'time', sys.argv[1]):
			tstype = 't'
		elif re.search(r'none', sys.argv[1]):
			tstype = 'x'
		else:
			tstype = 'unknown'

res = subprocess.call(["mkdir", 'pages'],  stdout=subprocess.PIPE) # same forlder name as Logseq
res = subprocess.call(["mkdir", 'assets'], stdout=subprocess.PIPE) # same forlder name as Logseq
res = subprocess.call(["mkdir", 'tmp'],    stdout=subprocess.PIPE)

for filename in glob.glob("*.docx"): # pandoc doesn't support doc
	
	## user dependant : change converted format to link journal note (use r'[[date]] with your setting format)
	if   tstype == 'x': # no timestamp in file/folder name
		tstamp = datetime.datetime.now().strftime("%Y%m%d")
		flname = re.sub(r'(.*)\.docx',r'\1',filename)
		converted = re.sub(r'([0-9]{4})([0-9]{2})([0-9]{2})',r'\1-\2-\3',tstamp) 
	elif tstype == 'd': # add date in file/folder name
		tstamp = datetime.datetime.now().strftime("%Y%m%d")
		flname = re.sub(r'(.*)\.docx',r'\1',filename) + " - " + tstamp
		converted = re.sub(r'([0-9]{4})([0-9]{2})([0-9]{2})',r'\1-\2-\3',tstamp) 
	elif tstype == 't': # add date,time stamp in file/folder name
		tstamp = datetime.datetime.now().strftime("%Y%m%d-%H%M")
		flname = re.sub(r'(.*)\.docx',r'\1',filename) + " - " +  tstamp
		converted = re.sub(r'([0-9]{4})([0-9]{2})([0-9]{2})-([0-9]{2})([0-9]{2})',r'\1-\2-\3 \4:\5',tstamp)
		
	mdname = flname + ".md"

	if mdname in mdfiles:
		print ("Exists: " + filename)
	else:
		print("converting: " + filename)
		res = subprocess.call([
			"pandoc", filename,
			"--wrap=none",
			"--extract-media=assets/" + flname,
			"-t","gfm",
			"-o", mdname], stdout=subprocess.PIPE) 
        
		with open(mdname) as mdfile:
			with open("tmp/" + mdname, mode='w') as newfile:

				## user dependant - customise here
				if re.match(r'SystemName\s+[.0-9x]+\s+[A-Za-z].+', filename):
					tags = re.sub(r'SystemName\s+([.0-9x]+)\s+([A-Za-z].+)$', r'#SystemName/\2/\1', filename)
				else:
					elements = re.split(r'\s*-\s*', re.sub(r'(.*)\.docx',r'\1',filename))
					if re.match(r'^[.0-9]+$', elements[0]):
						elements.append(elements.pop(0))
						# elements.append(tstamp)
						tags = "#" + '/'.join(elements)
					else:
						tags = ""
						
				# properties for logseq page
				headerLines = [
					"source:: " + filename + '\n',
					"title:: " + re.sub(r'(.*)\.docx',r'\1',filename) + '\n',
					"converted:: " + converted + '\n',
					"tags:: " + tags + '\n\n',
					"- {{renderer :tocgen, *, 5, h}}\n\n" # table of contents via TOC Generator
				]
				newfile.writelines(headerLines)
				newfile.write("# ")
				findingHeader = 0
				startFinding = 0
				for line in mdfile:
					tmpLine = line #.strip()
                    
                    # read line to replace heading
					if re.search(r'<span id=.+</span>.+', line): # headings
						tmpLine = re.sub(r'<span id=\".*\" class=\"anchor\"></span>(.+)\s*$', r'## [[\1]]',line)
						if re.search(r'\[\[Contents?\]\]', tmpLine):
							tmpLine = re.sub(r'\[\[(Contents?)\]\]', r' \1 ', tmpLine)
					elif re.search(r'^\s*\[(.+)\s\d+\]\(#.+\)\s*$', line): # toc
						tmpLine = re.sub(r'^\s*\[(.+) \d+\]\(#.+\)\s*$', r'\t- \1', line) # removed obsidian enhanced wikiname
					elif re.search(r'<span id=.+</span>$', line): # heading without sentences
						tmpLine = re.sub(r'<span id=\".*\" class=\"anchor\"></span>$', r'\n\n## [[FIX HERE]]',line)
                        
                    # adjust image size    
					if re.search(r'<img src=.+?(png|jpg|jpeg|gif|emf).+? />', tmpLine):
						result = re.findall(r'(<img src=\"(.+?\.)(png|jpg|jpeg|gif|emf)\" .*width:([.\d]+)in.+? />)', line)
						for m in result:							
							width = str(int(float(m[3]) * inch2pixel))
							tmpLine = tmpLine.replace(m[0], ' ![](../' + m[1] + m[2] + '){:width ' + width + '} ')
                            
					# write to file
					newfile.write(tmpLine)

		srcFile = open("tmp/" + mdname)
		srcText = srcFile.read()
		srcFile.close()
		
		## user dependant
		dstText = re.subn(r'^## \[\[FIX HERE\]\][\s\*]+(\S)', r'## \1', srcText, 0, re.MULTILINE)[0] # find next meaningful sentence
		dstText = re.subn(r'(</?tr.*?>)', r'\n \1', dstText)[0] # easy reading
		dstText = re.subn(r'(<td.*?</td>)', r'\n  \1', dstText)[0] # easy reading
		dstText = re.subn(r'(<th.*?</th>)', r'\n  \1', dstText)[0] # easy reading
		dstText = re.subn(r'<blockquote><p>(.*?)</p></blockquote>', r'\1', dstText)[0] # some cell in table has this
		
		dstFile = open("pages/" + mdname, mode='w')
		dstFile.write(dstText)
		dstFile.close()
						
		#res = subprocess.call(["mkdir", "result"], stdout=subprocess.PIPE)
		#res = subprocess.call(["mv", "images", "*.md", "result/"], stdout=subprocess.PIPE)
		res = subprocess.call(["rm", mdname], stdout=subprocess.PIPE)
		# mediaSeq = mediaSeq + 1
		
res = subprocess.call(["rm", '-rf', './tmp'], stdout=subprocess.PIPE)


カスタマイズ

バージョン管理

  • 18行目: 変換後のファイル/フォルダ名に付加するタイムスタンプのデフォルト値を設定 (引数指定も可能)

    プロパティ(converted) ファイル/フォルダ名 付加 実行時引数
    t (デフォルト) YYYY-MM-DD HH:MM YYYYMMDD-HHmm -ts=time
    d YYYY-MM-DD YYYYMMDD -ts=date
    x YYYY-MM-DD なし -ts=none
  • t,d では、ファイル名にタイムスタンプを付加しますが、title プロパティを元ファイル名で設定しているのでページ名は元ファイル名で表示されます。
    • 内容更新などで同じファイル名を変換した場合も、 Markdown およびイメージファイルは上書きされません。
    • Logseq でのインデックス再構築時に、同じタイトルがある旨のエラーが表示されます。
      • diff アプリなどを利用して差分を確認し、以下のいずれかの方法で対処してください。
        • 一方を削除
          • 元ファイルへの内容変更やコメント追加などを行っていた場合は、元ファイルへ同様の修正を入れた方が楽です。
        • 両方を残す
          • 古い方の title プロパティに converted プロパティの日付を付加するなどで title を変更すれば両方残せます。

日誌リンク

  • 40,44,48行目: 変換日プロパティ設定
    • 日誌を張る場合、日付の書き方を日付フォーマットに合わせた WikiName に変更してください。
      • (例: YYYY-MM-DD なら、[[\1-\2-\3]] )

タグ

  • 66~76行目のブロック: タグ作成
    • タグは階層化されるので、バージョン管理も含めて複数の階層付きタグを設定することをおすすめです。

目次

  • 84行目: 目次作成
    • 必要に応じて目次プラグイン TOC Generator のオプションを変更してください。
      • レベル5までの # 始まりを目次としています。

広告

%d人のブロガーが「いいね」をつけました。