Files
OBE-Mapping/app/generator/portofolio_docx.py
T
Power BI Dev 5f615aa7e8 Rapikan tampilan nilai, ganti NPM->NIM, fitur ganti password, kunci RPS view-only
- Tampilan "Isi Per Komponen Penilaian" diganti dari tombol bertumpuk teks
  jadi tabel (Komponen/Sub-CPMK/Bobot/Status/Aksi) dengan status berupa
  stempel, lebih mudah dipindai.
- Filter pencarian MK (ketik kode/nama) di kotak penugasan dosen /admin.
- Istilah NPM diseragamkan jadi NIM di seluruh aplikasi (label, header,
  template Excel, dokumen portofolio), termasuk migrasi kolom database
  mahasiswa.npm -> nim yang aman (ALTER TABLE RENAME COLUMN, otomatis saat
  startup, tanpa kehilangan data; parser Excel tetap terima file lama
  berheader NPM).
- Fitur ganti password mandiri (/akun): verifikasi password lama, sesi lain
  otomatis keluar setelah berhasil, sesi yang dipakai tetap aktif.
- RPS: dosen RPS+MK kini bisa menyesuaikan redaksi kalimat Sub-CPMK (bagian
  D) untuk MK yang diampu -- kode & kaitan CPMK/CPL tetap dari kurikulum,
  hanya teksnya yang bisa disesuaikan. Penyesuaian otomatis konsisten di
  dropdown mingguan (F), checklist komponen (G), serta unduhan RPS &
  portofolio, tanpa menyentuh data kurikulum induk atau halaman nilai/
  dashboard prodi.
- Form RPS dibungkus <fieldset disabled> untuk dosen tanpa hak edit --
  seluruh field benar-benar terkunci (bukan cuma tombol simpan hilang).
2026-07-22 08:37:54 +07:00

439 lines
18 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# -*- coding: utf-8 -*-
"""
Generator Portofolio Mata Kuliah (.docx).
Struktur mengikuti `Contoh Portofolio MK 040126.xlsx`:
Cover · Lembar Pengesahan · A CPL · B CPMK & Sub-CPMK · C Korelasi CPLSub-CPMK
· D Rencana Penilaian & Tugas · E Realisasi Pembelajaran · F Penilaian
· G Evaluasi Ketercapaian · H Simpulan & Tindak Lanjut.
"""
import io
import os
import pathlib
from docx import Document
from docx.enum.section import WD_ORIENT
from docx.enum.table import WD_ALIGN_VERTICAL
from docx.enum.text import WD_ALIGN_PARAGRAPH, WD_BREAK
from docx.oxml import OxmlElement
from docx.oxml.ns import qn
from docx.shared import Cm, Pt, RGBColor
DARK_BLUE = "1F4E79"
LIGHT_BLUE = "D6E4F0"
RED = "9A3324"
WHITE = "FFFFFF"
LOGO = pathlib.Path(__file__).resolve().parent.parent / "static" / "logo-untan.png"
FAKULTAS = os.environ.get("FAKULTAS", "FAKULTAS TEKNIK")
PRODI = os.environ.get("PRODI", "PROGRAM STUDI INFORMATIKA")
def _shd(cell, hex_color):
tc_pr = cell._tc.get_or_add_tcPr()
el = OxmlElement("w:shd")
el.set(qn("w:val"), "clear")
el.set(qn("w:color"), "auto")
el.set(qn("w:fill"), hex_color)
tc_pr.append(el)
def _ct(cell, text, bold=False, italic=False, size=9, color=None,
align=WD_ALIGN_PARAGRAPH.LEFT):
p = cell.paragraphs[0]
p.alignment = align
p.paragraph_format.space_before = Pt(1)
p.paragraph_format.space_after = Pt(1)
run = p.add_run()
for i, line in enumerate(str(text or "").split("\n")):
if i:
run.add_break()
run.add_text(line)
run.bold = bold
run.italic = italic
run.font.name = "Times New Roman"
run.font.size = Pt(size)
if color:
run.font.color.rgb = RGBColor.from_string(color)
cell.vertical_alignment = WD_ALIGN_VERTICAL.CENTER
def _hdr(cell, text, size=9):
_shd(cell, DARK_BLUE)
_ct(cell, text, bold=True, color=WHITE, size=size, align=WD_ALIGN_PARAGRAPH.CENTER)
def _borders(table):
tbl_pr = table._tbl.tblPr
el = OxmlElement("w:tblBorders")
for sisi in ("top", "left", "bottom", "right", "insideH", "insideV"):
b = OxmlElement(f"w:{sisi}")
b.set(qn("w:val"), "single")
b.set(qn("w:sz"), "4")
b.set(qn("w:color"), "000000")
el.append(b)
tbl_pr.append(el)
def _f(x, nd=2):
return f"{x:.{nd}f}" if isinstance(x, (int, float)) else "—"
def build_portofolio(d: dict) -> bytes:
doc = Document()
for sec in doc.sections:
sec.orientation = WD_ORIENT.LANDSCAPE
sec.page_width, sec.page_height = Cm(29.7), Cm(21.0)
sec.top_margin = sec.bottom_margin = Cm(2)
sec.left_margin = sec.right_margin = Cm(2)
ident = d.get("identitas", {})
dok = d.get("dokumen", {})
def para(text, size=11, bold=False, italic=False, align=WD_ALIGN_PARAGRAPH.LEFT,
before=6, after=3):
p = doc.add_paragraph()
p.alignment = align
p.paragraph_format.space_before = Pt(before)
p.paragraph_format.space_after = Pt(after)
r = p.add_run(text)
r.bold, r.italic = bold, italic
r.font.name = "Times New Roman"
r.font.size = Pt(size)
return p
def bagian(judul):
para(judul, size=12, bold=True, before=14)
# ── COVER ──
for _ in range(3):
doc.add_paragraph()
para("PORTOFOLIO MATA KULIAH", size=22, bold=True, align=WD_ALIGN_PARAGRAPH.CENTER)
para(f"{ident.get('nama', '')} ({ident.get('kode', '')})", size=16, bold=True,
align=WD_ALIGN_PARAGRAPH.CENTER)
doc.add_paragraph()
if LOGO.exists():
p = doc.add_paragraph()
p.alignment = WD_ALIGN_PARAGRAPH.CENTER
p.add_run().add_picture(str(LOGO), width=Cm(4.5))
doc.add_paragraph()
para(PRODI, size=13, bold=True, align=WD_ALIGN_PARAGRAPH.CENTER, after=0)
para(FAKULTAS, size=12, align=WD_ALIGN_PARAGRAPH.CENTER, before=0, after=0)
para("UNIVERSITAS TANJUNGPURA", size=12, align=WD_ALIGN_PARAGRAPH.CENTER,
before=0, after=0)
para(f"TAHUN AJARAN {d.get('tahun', '')}", size=12, align=WD_ALIGN_PARAGRAPH.CENTER,
before=0)
doc.paragraphs[-1].runs[-1].add_break(WD_BREAK.PAGE)
# ── LEMBAR PENGESAHAN ──
para("LEMBAR PENGESAHAN", size=14, bold=True, align=WD_ALIGN_PARAGRAPH.CENTER)
para(f"PORTOFOLIO MATA KULIAH — KURIKULUM OBE {dok.get('kurikulum_tahun', '')}",
size=11, align=WD_ALIGN_PARAGRAPH.CENTER)
doc.add_paragraph()
ti = doc.add_table(rows=5, cols=3)
_borders(ti)
isian = [
("Nama Dokumen", f"Portofolio MK {ident.get('nama', '')}"),
("Nomor Dokumen", dok.get("nomor", "") or "—"),
("No. Revisi", dok.get("revisi", "") or "—"),
("Tanggal Terbit", dok.get("tgl_terbit", "") or "—"),
("Status Dokumen", dok.get("status_dokumen", "Master")),
]
for i, (k, v) in enumerate(isian):
_shd(ti.cell(i, 0), LIGHT_BLUE)
_ct(ti.cell(i, 0), k, bold=True, size=10)
_ct(ti.cell(i, 1), ":", size=10, align=WD_ALIGN_PARAGRAPH.CENTER)
_ct(ti.cell(i, 2), v, size=10)
ti.rows[i].cells[0].width = Cm(5)
ti.rows[i].cells[1].width = Cm(0.7)
ti.rows[i].cells[2].width = Cm(12)
doc.add_paragraph()
tt = doc.add_table(rows=4, cols=3)
_borders(tt)
for ci, (peran, nama) in enumerate([
("DISUSUN OLEH\nKoordinator Mata Kuliah", ident.get("koordinator", "")),
("DIPERIKSA OLEH\nKetua Program Studi", ident.get("kaprodi", "")),
("DISAHKAN OLEH\nDekan", dok.get("dekan", "")),
]):
_hdr(tt.cell(0, ci), peran, size=10)
_ct(tt.cell(1, ci), "\n\n\n")
_ct(tt.cell(2, ci), f"( {nama or '______________________'} )",
align=WD_ALIGN_PARAGRAPH.CENTER, size=10)
_ct(tt.cell(3, ci), "NIP. ......................................",
align=WD_ALIGN_PARAGRAPH.CENTER, size=10)
doc.paragraphs[-1].runs[-1].add_break(WD_BREAK.PAGE) if doc.paragraphs[-1].runs else doc.add_page_break()
# ── A. CPL ──
bagian("A. CAPAIAN PEMBELAJARAN LULUSAN (CPL) YANG DIBEBANKAN PADA MK")
cpls = d.get("cpl", [])
ta = doc.add_table(rows=1 + len(cpls), cols=2)
_borders(ta)
_hdr(ta.cell(0, 0), "Kode CPL")
_hdr(ta.cell(0, 1), "Uraian CPL")
for i, c in enumerate(cpls, 1):
_ct(ta.cell(i, 0), c["kode"], align=WD_ALIGN_PARAGRAPH.CENTER)
_ct(ta.cell(i, 1), c["desk"])
ta.rows[i].cells[0].width = Cm(3)
ta.rows[i].cells[1].width = Cm(22)
# ── B. CPMK & Sub-CPMK ──
bagian("B. CPMK DAN SUB-CPMK")
cpmks = d.get("cpmk", [])
tb = doc.add_table(rows=1 + len(cpmks), cols=3)
_borders(tb)
_hdr(tb.cell(0, 0), "Kode CPMK")
_hdr(tb.cell(0, 1), "Uraian CPMK")
_hdr(tb.cell(0, 2), "CPL Didukung")
for i, c in enumerate(cpmks, 1):
_ct(tb.cell(i, 0), c["kode"], align=WD_ALIGN_PARAGRAPH.CENTER)
_ct(tb.cell(i, 1), c["desk"])
_ct(tb.cell(i, 2), ", ".join(c.get("cpl", [])), align=WD_ALIGN_PARAGRAPH.CENTER)
doc.add_paragraph()
subs = d.get("sub", [])
ts = doc.add_table(rows=1 + len(subs), cols=3)
_borders(ts)
_hdr(ts.cell(0, 0), "Kode Sub-CPMK")
_hdr(ts.cell(0, 1), "Kemampuan Akhir Tiap Tahapan Belajar")
_hdr(ts.cell(0, 2), "CPMK")
for i, s in enumerate(subs, 1):
_ct(ts.cell(i, 0), s["kode"], align=WD_ALIGN_PARAGRAPH.CENTER)
_ct(ts.cell(i, 1), s["uraian"])
_ct(ts.cell(i, 2), s.get("cpmk", ""), align=WD_ALIGN_PARAGRAPH.CENTER)
# ── C. korelasi CPLSub-CPMK ──
bagian("C. KORELASI CPL TERHADAP SUB-CPMK (BOBOT PENILAIAN, %)")
korelasi = d.get("korelasi", {}) # {sub: {cpl: porsi}}
bobot_sub = d.get("bobot_sub", {})
minggu_sub = d.get("minggu_sub", {})
kode_cpl = [c["kode"] for c in cpls]
tc = doc.add_table(rows=1 + len(subs) + 1, cols=3 + len(kode_cpl))
_borders(tc)
_hdr(tc.cell(0, 0), "Sub-CPMK")
for j, cp in enumerate(kode_cpl):
_hdr(tc.cell(0, 1 + j), f"{cp}\n(%)")
_hdr(tc.cell(0, 1 + len(kode_cpl)), "Bobot\nPenilaian (%)")
_hdr(tc.cell(0, 2 + len(kode_cpl)), "Jumlah\nMinggu")
tot_cpl = {cp: 0.0 for cp in kode_cpl}
tot_bobot = 0.0
for i, s in enumerate(subs, 1):
_ct(tc.cell(i, 0), s["kode"], align=WD_ALIGN_PARAGRAPH.CENTER)
for j, cp in enumerate(kode_cpl):
v = korelasi.get(s["kode"], {}).get(cp)
_ct(tc.cell(i, 1 + j), _f(v, 1) if v else "", align=WD_ALIGN_PARAGRAPH.CENTER)
tot_cpl[cp] += v or 0
b = bobot_sub.get(s["kode"])
tot_bobot += b or 0
_ct(tc.cell(i, 1 + len(kode_cpl)), _f(b, 1) if b else "—",
align=WD_ALIGN_PARAGRAPH.CENTER)
_ct(tc.cell(i, 2 + len(kode_cpl)), str(minggu_sub.get(s["kode"], "")),
align=WD_ALIGN_PARAGRAPH.CENTER)
last = len(subs) + 1
_shd(tc.cell(last, 0), LIGHT_BLUE)
_ct(tc.cell(last, 0), "Total", bold=True, align=WD_ALIGN_PARAGRAPH.RIGHT)
for j, cp in enumerate(kode_cpl):
_shd(tc.cell(last, 1 + j), LIGHT_BLUE)
_ct(tc.cell(last, 1 + j), _f(tot_cpl[cp], 1), bold=True,
align=WD_ALIGN_PARAGRAPH.CENTER)
_shd(tc.cell(last, 1 + len(kode_cpl)), LIGHT_BLUE)
_ct(tc.cell(last, 1 + len(kode_cpl)), _f(tot_bobot, 1), bold=True,
align=WD_ALIGN_PARAGRAPH.CENTER)
_shd(tc.cell(last, 2 + len(kode_cpl)), LIGHT_BLUE)
# ── D. rencana penilaian & tugas (dari RPS) ──
bagian("D. RENCANA PENILAIAN DAN RENCANA TUGAS (DARI RPS)")
rencana = d.get("rencana", [])
td = doc.add_table(rows=1 + max(len(rencana), 1), cols=4)
_borders(td)
for ci, h in enumerate(["Minggu ke-", "Sub-CPMK", "Bentuk Asesmen", "Bobot (%)"]):
_hdr(td.cell(0, ci), h)
for i, r in enumerate(rencana, 1):
_ct(td.cell(i, 0), str(r["minggu"]), align=WD_ALIGN_PARAGRAPH.CENTER)
_ct(td.cell(i, 1), r["sub"])
_ct(td.cell(i, 2), r["asesmen"])
_ct(td.cell(i, 3), str(r["bobot"]), align=WD_ALIGN_PARAGRAPH.CENTER)
para("RPS lengkap diterbitkan sebagai dokumen terpisah dari aplikasi yang sama.",
size=9, italic=True)
# ── E. realisasi ──
bagian("E. REALISASI PEMBELAJARAN")
te = doc.add_table(rows=1, cols=1)
_borders(te)
_ct(te.cell(0, 0), d.get("realisasi", "") or "—", size=10)
# ── F. penilaian ──
bagian("F. PENILAIAN MATA KULIAH")
nilai = d.get("nilai", {})
skor_sub = d.get("skor_sub", {})
if nilai.get("mode") == "per_mahasiswa":
mhs = nilai.get("mahasiswa", [])
akhir = {a["nim"]: a["akhir"] for a in d.get("akhir", [])}
kol_sub = [s["kode"] for s in subs]
tf = doc.add_table(rows=2 + len(mhs) + 3, cols=3 + len(kol_sub) + 1)
_borders(tf)
_hdr(tf.cell(0, 0), "No.")
_hdr(tf.cell(0, 1), "NIM")
_hdr(tf.cell(0, 2), "Nama")
for j, sk in enumerate(kol_sub):
_hdr(tf.cell(0, 3 + j), sk, size=7)
_hdr(tf.cell(0, 3 + len(kol_sub)), "Nilai\nAkhir", size=7)
# baris bobot
_shd(tf.cell(1, 0), LIGHT_BLUE)
_ct(tf.cell(1, 0), "Bobot", bold=True, size=8)
for j, sk in enumerate(kol_sub):
_shd(tf.cell(1, 3 + j), LIGHT_BLUE)
b = d.get("bobot_sub", {}).get(sk)
_ct(tf.cell(1, 3 + j), _f((b or 0) / 100, 2), size=8,
align=WD_ALIGN_PARAGRAPH.CENTER)
for i, m in enumerate(mhs, 2):
_ct(tf.cell(i, 0), str(i - 1), size=8, align=WD_ALIGN_PARAGRAPH.CENTER)
_ct(tf.cell(i, 1), m.get("nim", ""), size=8)
_ct(tf.cell(i, 2), m.get("nama", ""), size=8)
for j, sk in enumerate(kol_sub):
v = (m.get("skor") or {}).get(sk)
_ct(tf.cell(i, 3 + j), _f(v, 0) if v is not None else "",
size=8, align=WD_ALIGN_PARAGRAPH.CENTER)
_ct(tf.cell(i, 3 + len(kol_sub)), _f(akhir.get(m.get("nim", "")), 1),
size=8, bold=True, align=WD_ALIGN_PARAGRAPH.CENTER)
# baris statistik per sub
base = 2 + len(mhs)
for k, (label, kunci) in enumerate([("Rata-rata", "rata"),
("Maksimum", "max"), ("Minimum", "min")]):
_shd(tf.cell(base + k, 0), LIGHT_BLUE)
_ct(tf.cell(base + k, 0), label, bold=True, size=8)
for j, sk in enumerate(kol_sub):
info = skor_sub.get(sk)
if not info:
continue
if kunci == "rata":
v = info["rata"]
else:
xs = [(m.get("skor") or {}).get(sk) for m in mhs]
xs = [x for x in xs if x is not None]
v = (max(xs) if kunci == "max" else min(xs)) if xs else None
_ct(tf.cell(base + k, 3 + j), _f(v, 1), size=8,
align=WD_ALIGN_PARAGRAPH.CENTER)
else:
para("Mode input: rata-rata kelas per Sub-CPMK (BUKTI AGREGAT — tidak "
"memuat data per mahasiswa).", size=10, italic=True)
tf = doc.add_table(rows=1 + len(subs), cols=2)
_borders(tf)
_hdr(tf.cell(0, 0), "Sub-CPMK")
_hdr(tf.cell(0, 1), "Rata-rata Kelas")
for i, s in enumerate(subs, 1):
info = skor_sub.get(s["kode"])
_ct(tf.cell(i, 0), s["kode"], align=WD_ALIGN_PARAGRAPH.CENTER)
_ct(tf.cell(i, 1), _f(info["rata"], 2) if info else "—",
align=WD_ALIGN_PARAGRAPH.CENTER)
doc.add_paragraph()
skala = d.get("skala", [])
tsx = doc.add_table(rows=1 + len(skala), cols=3)
_borders(tsx)
_hdr(tsx.cell(0, 0), "Huruf")
_hdr(tsx.cell(0, 1), "Rentang Nilai")
_hdr(tsx.cell(0, 2), "Angka Mutu")
for i, s in enumerate(skala, 1):
_ct(tsx.cell(i, 0), s["huruf"], align=WD_ALIGN_PARAGRAPH.CENTER)
_ct(tsx.cell(i, 1), f"{s['lo']:g} {s['hi']:g}", align=WD_ALIGN_PARAGRAPH.CENTER)
_ct(tsx.cell(i, 2), _f(s["angka"], 2) if s["angka"] is not None else "—",
align=WD_ALIGN_PARAGRAPH.CENTER)
# ── G. evaluasi ketercapaian ──
bagian("G. EVALUASI KETERCAPAIAN CPL DAN CPMK")
rollup = d.get("rollup", {})
ambang = d.get("ambang", 70)
baris_g = []
for cp in cpls:
subs_cp = [s for s in subs
if any(c["kode"] == s.get("cpmk") and cp["kode"] in c.get("cpl", [])
for c in cpmks)]
for k, s in enumerate(subs_cp):
info = skor_sub.get(s["kode"])
baris_g.append({
"cpl": cp["kode"] if k == 0 else "",
"sub": s["kode"],
"rata_sub": info["rata"] if info else None,
"rata_cpl": rollup.get("cpl", {}).get(cp["kode"], {}).get("skor")
if k == 0 else None,
"lulus": info["lulus_pct"] if info else None,
"pertama": k == 0,
"kode_cpl": cp["kode"],
})
tg = doc.add_table(rows=1 + max(len(baris_g), 1), cols=6)
_borders(tg)
for ci, h in enumerate(["CPL", "Sub-CPMK", "Rata-rata Nilai\nSub-CPMK",
"Rata-rata\nNilai CPL", "% Kelulusan\n(≥ ambang)",
"Kesimpulan"]):
_hdr(tg.cell(0, ci), h)
for i, b in enumerate(baris_g, 1):
_ct(tg.cell(i, 0), b["cpl"], bold=True, align=WD_ALIGN_PARAGRAPH.CENTER)
_ct(tg.cell(i, 1), b["sub"], align=WD_ALIGN_PARAGRAPH.CENTER)
_ct(tg.cell(i, 2), _f(b["rata_sub"]), align=WD_ALIGN_PARAGRAPH.CENTER)
_ct(tg.cell(i, 3), _f(b["rata_cpl"]) if b["pertama"] else "",
bold=True, align=WD_ALIGN_PARAGRAPH.CENTER)
_ct(tg.cell(i, 4), _f(b["lulus"], 1) if b["lulus"] is not None else "—",
align=WD_ALIGN_PARAGRAPH.CENTER)
if b["pertama"]:
skor = b["rata_cpl"]
if skor is None:
ket, warna = "Belum terukur", None
elif skor >= ambang:
ket, warna = "TERCAPAI", None
else:
ket, warna = "BELUM TERCAPAI", RED
_ct(tg.cell(i, 5), ket, bold=True, color=warna,
align=WD_ALIGN_PARAGRAPH.CENTER)
para(f"Ambang ketercapaian: {ambang:g}. Metode: rata-rata sederhana (bagi rata) "
"sesuai Metodologi Pengukuran prodi.", size=9, italic=True)
# ── H. simpulan ──
bagian("H. SIMPULAN HASIL EVALUASI DAN TINDAK LANJUT")
stat = d.get("statistik")
if stat:
th = doc.add_table(rows=7, cols=2)
_borders(th)
_hdr(th.cell(0, 0), "Statistik Deskriptif (Nilai Akhir)")
_hdr(th.cell(0, 1), "Nilai")
for i, (k, v) in enumerate([
("Jumlah mahasiswa", stat["n"]), ("Mean", _f(stat["mean"])),
("Median", _f(stat["median"])), ("Standar deviasi", _f(stat["stdev"])),
("Minimum", _f(stat["min"])), ("Maksimum rentang",
f"{_f(stat['max'])} {_f(stat['range'])}"),
], 1):
_ct(th.cell(i, 0), k)
_ct(th.cell(i, 1), str(v), align=WD_ALIGN_PARAGRAPH.CENTER)
doc.add_paragraph()
frek = stat["frekuensi"]
tfr = doc.add_table(rows=2, cols=1 + len(frek))
_borders(tfr)
_hdr(tfr.cell(0, 0), "Huruf")
_shd(tfr.cell(1, 0), LIGHT_BLUE)
_ct(tfr.cell(1, 0), "Frekuensi", bold=True)
for j, (h, n) in enumerate(frek.items(), 1):
_hdr(tfr.cell(0, j), h)
_ct(tfr.cell(1, j), str(n), align=WD_ALIGN_PARAGRAPH.CENTER)
else:
para("Statistik nilai akhir tidak tersedia (mode bukti agregat).",
size=10, italic=True)
doc.add_paragraph()
kendala = [k for k in d.get("kendala", []) if k.get("kendala")]
tk = doc.add_table(rows=1 + max(len(kendala), 1), cols=3)
_borders(tk)
_hdr(tk.cell(0, 0), "No.")
_hdr(tk.cell(0, 1), "Kendala")
_hdr(tk.cell(0, 2), "Tindak Lanjut")
for i, k in enumerate(kendala, 1):
_ct(tk.cell(i, 0), str(i), align=WD_ALIGN_PARAGRAPH.CENTER)
_ct(tk.cell(i, 1), k.get("kendala", ""))
_ct(tk.cell(i, 2), k.get("tindak", ""))
if d.get("simpulan"):
doc.add_paragraph()
ts2 = doc.add_table(rows=1, cols=1)
_borders(ts2)
_ct(ts2.cell(0, 0), d["simpulan"], size=10)
buf = io.BytesIO()
doc.save(buf)
return buf.getvalue()