当社の名刺には、電話番号やメアドなどを簡単に登録できるようにVcard情報を入れたQRコードを印刷しているたのだが、ちょっとした変更があるたびにQRコードを作成して、印刷後に検証したりといったちょっと煩わしい作業となる。
検索するといろんなQRコード作成ツールが見つかるが、いろんな情報を入れようとすると最終的にどの程度の細かさになるとかをリアルタイムで確認したくなる。
そこで勉強も兼ねてQRコードを作成するツールを製作することにした。
"""
vCard QR Generator (Python/Tkinter)
© 2026 Sketlab LLC.
MIT License
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
"""
import tkinter as tk
from tkinter import ttk, filedialog, messagebox
import re
import quopri
import qrcode
import base64
from PIL import Image, ImageTk
# -----------------------------
# vCard utility
# -----------------------------
def normalize_tel(s: str) -> str:
"""電話番号の正規化:数字と先頭の'+'記号のみを残し、ハイフンなどを除去します。"""
if not s:
return ""
s = s.strip()
if s.startswith("+"):
return "+" + re.sub(r"[^\d]", "", s[1:])
return re.sub(r"[^\d]", "", s)
def escape_vcard_text_30(s: str) -> str:
"""vCard 3.0規格に従い、セミコロンや改行などの特殊文字をバックスラッシュでエスケープします。"""
if s is None:
return ""
s = str(s)
s = s.replace("\\", "\\\\")
s = s.replace("\r\n", "\n").replace("\r", "\n")
s = s.replace("\n", "\\n")
s = s.replace(";", "\\;")
s = s.replace(",", "\\,")
return s
def unescape_vcard_text_30(s: str) -> str:
"""エスケープされたvCardテキストを元の文字列に戻します(読み込み処理用)。"""
if s is None:
return ""
s = str(s)
s = s.replace("\\n", "\n")
s = s.replace("\\,", ",")
s = s.replace("\\;", ";")
s = s.replace("\\\\", "\\")
return s
def fold_vcard_line(line: str, limit: int = 75) -> str:
"""1行が長すぎる場合に改行とスペースを挿入し、vCardの「行折り返し」規格に適合させます。"""
if len(line) <= limit:
return line
out = []
while len(line) > limit:
out.append(line[:limit])
line = " " + line[limit:]
out.append(line)
return "\r\n".join(out)
def qp_sjis_value(text: str) -> str:
"""Outlook(vCard 2.1)向け:文字列をShift_JISでエンコードし、Quoted-Printable形式に変換します。"""
if not text:
return ""
b = text.encode("cp932")
return quopri.encodestring(b, quotetabs=True).decode("ascii")
def split_japanese_address(addr: str):
"""日本の住所文字列を、正規表現を用いて「市区町村」と「それ以降」に分割します。
例:
'大阪市北区梅田1-1-3大阪駅前第3ビル29階1-1-1号室'
↓
locality = '大阪市北区'
street = '梅田1-1-3大阪駅前第3ビル29階1-1-1号室'
"""
if not addr:
return "", ""
m = re.match(r"(.*?[市区町村])(.*)", addr)
if m:
return m.group(1), m.group(2).lstrip()
return "", addr
def build_vcard_30(data: dict) -> str:
"""iOS/Android向けのvCard 3.0形式のデータを生成します(UTF-8)。"""
last = escape_vcard_text_30(data.get("last", "").strip())
first = escape_vcard_text_30(data.get("first", "").strip())
last_kana = escape_vcard_text_30(data.get("last_kana", "").strip())
first_kana = escape_vcard_text_30(data.get("first_kana", "").strip())
org = escape_vcard_text_30(data.get("org", "").strip())
title = escape_vcard_text_30(data.get("title", "").strip())
tel = escape_vcard_text_30(normalize_tel(data.get("tel", "").strip()))
mobile = escape_vcard_text_30(normalize_tel(data.get("mobile", "").strip()))
email = escape_vcard_text_30(data.get("email", "").strip())
url = escape_vcard_text_30(data.get("url", "").strip())
addr = escape_vcard_text_30(data.get("addr", "").strip())
fn_raw = (f"{data.get('last','').strip()} {data.get('first','').strip()}").strip()
fn = escape_vcard_text_30(fn_raw if fn_raw else (data.get("org", "").strip() or "Contact"))
lines = []
lines.append("BEGIN:VCARD")
lines.append("VERSION:3.0")
lines.append(f"N:{last};{first};;;")
lines.append(f"FN:{fn}")
# NOTE: Android側の「セイ?メイ」問題は避けにくいですが、
# SORT-STRINGは iOS/macOS の並び替えに有用で、害は少ないので入れておきます。
kana_full = " ".join([x for x in [last_kana, first_kana] if x])
if kana_full:
lines.append(f"SORT-STRING:{kana_full}")
if org:
lines.append(f"ORG:{org}")
if title:
lines.append(f"TITLE:{title}")
if tel:
lines.append(f"TEL;TYPE=WORK,VOICE:{tel}")
if mobile:
lines.append(f"TEL;TYPE=CELL,VOICE:{mobile}")
if email:
lines.append(f"EMAIL;TYPE=INTERNET,WORK:{email}")
if url:
lines.append(f"URL:{url}")
# ADR: POBOX;EXT;STREET;LOCALITY;REGION;POSTAL;COUNTRY
# 日本住所はSTREETにまとめるのが現場的に扱いやすい
if addr:
lines.append(f"ADR;TYPE=WORK:;;{addr};;;;")
lines.append(
base64.b64decode("Tk9URTtDSEFSU0VUPVVURi04OsKpMjAyNiBTa2V0bGFiIExMQy4=")
.decode("utf-8"))
lines.append("END:VCARD")
# fold + CRLF
folded = [fold_vcard_line(x) for x in lines]
return "\r\n".join(folded) + "\r\n"
def build_vcard_21(data: dict) -> str:
"""Outlookなどの古いメーラー向けに、Shift_JIS + QPエンコードを用いたvCard 2.1形式を生成します。"""
def qp_line(prop: str, value: str) -> str:
if not value:
return ""
qp = qp_sjis_value(value)
return f"{prop};CHARSET=SHIFT_JIS;ENCODING=QUOTED-PRINTABLE:{qp}"
last = data.get("last", "").strip()
first = data.get("first", "").strip()
org = data.get("org", "").strip()
title = data.get("title", "").strip()
tel = normalize_tel(data.get("tel", "").strip())
mobile = normalize_tel(data.get("mobile", "").strip())
email = data.get("email", "").strip()
url = data.get("url", "").strip()
addr = data.get("addr", "").strip()
fn = (f"{last} {first}").strip() or org or "Contact"
locality, street = split_japanese_address(addr)
lines = []
lines.append("BEGIN:VCARD")
lines.append("VERSION:2.1")
if last or first:
lines.append(qp_line("N", f"{last};{first}"))
lines.append(qp_line("FN", fn))
lines.append(qp_line("ORG", org))
lines.append(qp_line("TITLE", title))
if tel:
lines.append(f"TEL;WORK;VOICE:{tel}")
if mobile:
lines.append(f"TEL;CELL;VOICE:{mobile}")
if email:
lines.append(f"EMAIL;INTERNET:{email}")
if url:
lines.append(f"URL:{url}")
if street or locality:
lines.append(
qp_line(
"ADR;WORK",
f";;{street};{locality};;;;"
)
)
lines.append(f"NOTE;CHARSET=SHIFT_JIS:{base64.b64decode('KGMpMjAyNiBTa2V0bGFiIExMQy4=').decode('ascii')}")
lines.append("END:VCARD")
return "\r\n".join(lines) + "\r\n"
def parse_vcard_text(text: str) -> dict:
"""既存のVCFファイルを解析し、各項目を辞書形式で抽出します。折り返し行の結合やQP復号に対応しています。"""
# Unfold: lines that start with space/tab continue previous line
raw_lines = text.splitlines()
lines = []
for line in raw_lines:
if not line:
continue
if line.startswith(" ") or line.startswith("\t"):
if lines:
lines[-1] += line[1:]
else:
lines.append(line.lstrip())
else:
lines.append(line)
out = {
"last": "", "last_kana": "",
"first": "", "first_kana": "",
"org": "", "title": "",
"tel": "", "mobile": "",
"email": "", "url": "",
"addr": ""
}
def decode_value(params: str, value: str) -> str:
# vCard 2.1 quoted-printable
params_u = params.upper()
if "ENCODING=QUOTED-PRINTABLE" in params_u:
try:
# value is ASCII with =XX
b = value.encode("ascii", errors="ignore")
b2 = quopri.decodestring(b)
# charset
if "CHARSET=UTF-8" in params_u:
return b2.decode("utf-8", errors="ignore")
# fallback
return b2.decode("utf-8", errors="ignore")
except Exception:
return value
# vCard 3.0 escaping
return unescape_vcard_text_30(value)
for line in lines:
if ":" not in line:
continue
head, value = line.split(":", 1)
head_parts = head.split(";")
prop = head_parts[0].upper()
params = ";".join(head_parts[1:]) if len(head_parts) > 1 else ""
val = decode_value(params, value)
if prop == "N":
parts = val.split(";")
if len(parts) >= 2:
out["last"] = parts[0]
out["first"] = parts[1]
elif prop == "SORT-STRING":
# Expect "last_kana first_kana"
tokens = val.strip().split()
if len(tokens) >= 1:
out["last_kana"] = tokens[0]
if len(tokens) >= 2:
out["first_kana"] = tokens[1]
elif prop == "ORG":
out["org"] = val
elif prop == "TITLE":
out["title"] = val
elif prop == "TEL":
params_u = params.upper()
num = val.strip()
if "CELL" in params_u:
out["mobile"] = num
else:
# prefer putting into tel if empty
if not out["tel"]:
out["tel"] = num
else:
# if tel exists and mobile empty, treat as mobile
if not out["mobile"]:
out["mobile"] = num
elif prop == "EMAIL":
out["email"] = val.strip()
elif prop == "URL":
out["url"] = val.strip()
elif prop == "ADR":
# ADR: POBOX;EXT;STREET;LOCALITY;REGION;POSTAL;COUNTRY
parts = val.split(";")
if len(parts) >= 3:
out["addr"] = parts[2].strip()
return out
def read_text_guess_encoding(path: str) -> str:
"""ファイルのエンコーディングを UTF-8, UTF-16, Shift_JIS の順で試行し、正しく読み込みます。"""
encodings = ["utf-8-sig", "utf-8", "utf-16", "utf-16-le", "cp932"]
last_err = None
for enc in encodings:
try:
with open(path, "r", encoding=enc, errors="strict") as f:
return f.read()
except Exception as e:
last_err = e
continue
# last resort: ignore errors
with open(path, "r", encoding="utf-8", errors="ignore") as f:
return f.read()
# -----------------------------
# GUI App
# -----------------------------
class VCardQRApp(tk.Tk):
def __init__(self):
super().__init__()
self.title("vCard QR Generator (VCF2.1 / VCF3.0)")
self.qr_img_pil = None
self.qr_img_tk = None
self.vcard_text = ""
self._update_job = None # debounce job id
self._build_ui()
self._bind_realtime_update()
def _build_ui(self):
main = ttk.Frame(self, padding=12)
main.grid(row=0, column=0, sticky="nsew")
self.columnconfigure(0, weight=1)
self.rowconfigure(0, weight=1)
main.columnconfigure(0, weight=1)
main.columnconfigure(1, weight=1)
# Left: input form
form = ttk.LabelFrame(main, text="入力", padding=10)
form.grid(row=0, column=0, sticky="nsew", padx=(0, 10))
form.columnconfigure(1, weight=1)
self.vars = {
"last": tk.StringVar(),
"last_kana": tk.StringVar(),
"first": tk.StringVar(),
"first_kana": tk.StringVar(),
"org": tk.StringVar(),
"title": tk.StringVar(),
"tel": tk.StringVar(),
"mobile": tk.StringVar(),
"email": tk.StringVar(),
"url": tk.StringVar(),
"addr": tk.StringVar(),
}
rows = [
("姓", "last"),
("姓(ふりがな)", "last_kana"),
("名", "first"),
("名(ふりがな)", "first_kana"),
("会社名", "org"),
("役職", "title"),
("電話番号", "tel"),
("携帯番号", "mobile"),
("メールアドレス", "email"),
("URL", "url"),
("会社住所", "addr"),
]
for r, (label, key) in enumerate(rows):
ttk.Label(form, text=label).grid(row=r, column=0, sticky="w", pady=3)
entry = ttk.Entry(form, textvariable=self.vars[key])
entry.grid(row=r, column=1, sticky="ew", pady=3)
# Buttons
btns = ttk.Frame(form)
btns.grid(row=len(rows), column=0, columnspan=2, sticky="ew", pady=(10, 0))
for c in range(3):
btns.columnconfigure(c, weight=1)
ttk.Button(btns, text="VCF2.1 保存(Outlook)", command=self.on_save_vcf21).grid(row=0, column=0, sticky="ew", padx=(0, 6))
ttk.Button(btns, text="VCF3.0 保存(QR/スマホ)", command=self.on_save_vcf30).grid(row=0, column=1, sticky="ew", padx=6)
ttk.Button(btns, text="VCF読込", command=self.on_load_vcf).grid(row=0, column=2, sticky="ew", padx=(6, 0))
ttk.Button(btns, text="QR保存(PNG)", command=self.on_save_qr).grid(row=1, column=0, columnspan=3, sticky="ew", pady=(8, 0))
hint = ttk.Label(form, text="※ QRは入力と同時に自動更新されます(ボタン不要)", foreground="#555")
hint.grid(row=len(rows)+1, column=0, columnspan=2, sticky="w", pady=(8, 0))
# Right: QR + vCard preview
right = ttk.Frame(main)
right.grid(row=0, column=1, sticky="nsew")
right.rowconfigure(1, weight=1)
right.columnconfigure(0, weight=1)
preview = ttk.LabelFrame(right, text="QRプレビュー(vCard3.0)", padding=10)
preview.grid(row=0, column=0, sticky="nsew")
preview.columnconfigure(0, weight=1)
self.qr_label = ttk.Label(preview, text="未入力", anchor="center")
self.qr_label.grid(row=0, column=0, sticky="nsew")
vbox = ttk.LabelFrame(right, text="生成されたvCard(表示/確認用)", padding=10)
vbox.grid(row=1, column=0, sticky="nsew", pady=(10, 0))
vbox.rowconfigure(0, weight=1)
vbox.columnconfigure(0, weight=1)
self.text = tk.Text(vbox, height=14, wrap="none")
self.text.grid(row=0, column=0, sticky="nsew")
yscroll = ttk.Scrollbar(vbox, orient="vertical", command=self.text.yview)
yscroll.grid(row=0, column=1, sticky="ns")
self.text.configure(yscrollcommand=yscroll.set)
def _bind_realtime_update(self):
for var in self.vars.values():
var.trace_add("write", self._schedule_update)
# 初期表示
self._schedule_update()
def _schedule_update(self, *args):
# debounce: avoid regenerating QR for every keystroke instantly
if self._update_job is not None:
try:
self.after_cancel(self._update_job)
except Exception:
pass
self._update_job = self.after(150, self.update_realtime)
def get_form_data(self) -> dict:
return {k: v.get() for k, v in self.vars.items()}
def update_realtime(self):
self._update_job = None
data = self.get_form_data()
# Minimal required: at least last/first/org one must exist
if not (data["last"].strip() or data["first"].strip() or data["org"].strip()):
self.vcard_text = ""
self.text.delete("1.0", "end")
self.text.insert("1.0", "")
self.qr_label.configure(text="未入力", image="")
self.qr_label.image = None
self.qr_img_pil = None
return
vcard = build_vcard_30(data)
self.vcard_text = vcard
# Show vCard text
self.text.delete("1.0", "end")
self.text.insert("1.0", vcard)
# Generate QR
self.generate_qr(vcard)
def generate_qr(self, vcard_text: str):
qr = qrcode.QRCode(
version=None,
error_correction=qrcode.constants.ERROR_CORRECT_M,
box_size=12,
border=3
)
qr.add_data(vcard_text)
qr.make(fit=True)
img = qr.make_image(fill_color="black", back_color="white").convert("RGB")
self.qr_img_pil = img
# preview size
max_px = 320
w, h = img.size
scale = min(max_px / w, max_px / h, 1.0)
disp = img.resize((int(w * scale), int(h * scale)))
self.qr_img_tk = ImageTk.PhotoImage(disp)
self.qr_label.configure(image=self.qr_img_tk, text="")
self.qr_label.image = self.qr_img_tk
def on_save_vcf21(self):
data = self.get_form_data()
if not (data["last"].strip() or data["first"].strip() or data["org"].strip()):
messagebox.showwarning("入力不足", "最低でも「姓 or 名 or 会社名」のどれかは入力してください。")
return
vcard = build_vcard_21(data)
path = filedialog.asksaveasfilename(
title="VCF2.1として保存(Outlook向け)",
defaultextension=".vcf",
filetypes=[("vCard files", "*.vcf"), ("All files", "*.*")]
)
if not path:
return
try:
# Outlook向け:UTF-8でOK(中身はQP/charset指定)
with open(path, "w", encoding="cp932", newline="") as f:
f.write(vcard)
messagebox.showinfo("保存完了", f"VCF2.1 を保存しました:\n{path}")
except Exception as e:
messagebox.showerror("保存エラー", str(e))
def on_save_vcf30(self):
data = self.get_form_data()
if not (data["last"].strip() or data["first"].strip() or data["org"].strip()):
messagebox.showwarning("入力不足", "最低でも「姓 or 名 or 会社名」のどれかは入力してください。")
return
vcard = build_vcard_30(data)
path = filedialog.asksaveasfilename(
title="VCF3.0として保存(QR/スマホ向け)",
defaultextension=".vcf",
filetypes=[("vCard files", "*.vcf"), ("All files", "*.*")]
)
if not path:
return
try:
# QR/スマホ向け:BOMなしUTF-8が扱いやすい
with open(path, "w", encoding="utf-8", newline="") as f:
f.write(vcard)
messagebox.showinfo("保存完了", f"VCF3.0 を保存しました:\n{path}")
except Exception as e:
messagebox.showerror("保存エラー", str(e))
def on_load_vcf(self):
path = filedialog.askopenfilename(
title="VCFを読み込む",
filetypes=[("vCard files", "*.vcf"), ("All files", "*.*")]
)
if not path:
return
try:
text = read_text_guess_encoding(path)
data = parse_vcard_text(text)
# Update form (pause realtime update briefly by setting then one schedule)
for k, v in data.items():
if k in self.vars:
self.vars[k].set(v)
# Trigger update
self._schedule_update()
except Exception as e:
messagebox.showerror("読込エラー", str(e))
def on_save_qr(self):
if self.qr_img_pil is None:
messagebox.showinfo("未生成", "QRコードがまだ生成されていません。")
return
path = filedialog.asksaveasfilename(
title="QRコードをPNGで保存",
defaultextension=".png",
filetypes=[("PNG image", "*.png")]
)
if not path:
return
try:
self.qr_img_pil.save(path, "PNG")
messagebox.showinfo("保存完了", f"QRコードを保存しました:\n{path}")
except Exception as e:
messagebox.showerror("保存エラー", str(e))
if __name__ == "__main__":
app = VCardQRApp()
app.mainloop()最近コードは、生成AIを使うとある程度までは作ってくれるのだが、なにか一つ躓くともとに戻れなくなって、Aを修正したらBが不具合、Bの不具合を修正したらAの不具合が再発、とか堂々巡りになるので、「あまり信用せず、AIの書いたコードを自分で読んでみて」どのあたりが整合とれていないとかを考えながらやらないとうまくいかない感じです。
もちろん作業効率はめちゃめちゃ上がっているのだが、自信満々に嘘をつくのは相変わらず。でも、前職時代に夜中に相談相手もなく一人で頭抱えていたことに比べたらずいぶんマシになっていると思うので、この先もうまく付き合っていくつもりでいる。
上記はPythonで書いたが、当web上にはJavascriptで書いたものを置いているので、急ぎ必要な方は、こちらをご利用ください。

URL欄に、QRコードを作りたいアドレスを記入して「QRコード生成」ボタンを押すと作成される

下部の入力欄に、姓名などの情報を入れていくと、QRコードが生成される。
既存のVCFファイルを保存/読込が出来るので、毎回打ち込まなくても保存したファイルを読み込めばOK。
【免責事項】
本ツールは無保証で提供されます。利用による損害について開発者は一切の責任を負いません。
また、本ツールに関する個別のサポートやお問い合わせへの対応は行っておりません。
あらかじめご了承の上、自己責任でご利用ください。

