374bdb5565
build_rps() mengisi blok naratif (Peta CPL-CPMK, Deskripsi Singkat MK, Bahan Kajian, Pustaka, Media, Dosen Pengampu, MK Prasyarat) lewat t.cell(18, 2) dst -- indeks baris tetap sesuai template asli (39 baris). Tapi baris-baris itu dieksekusi SETELAH daftar CPL/CPMK/Sub-CPMK digandakan & baris contohnya dibuang -- jumlah baris yang disisipkan beda-beda tiap MK (tergantung berapa CPL/CPMK/Sub-CPMK-nya), jadi t.cell(18, 2) di tabel yang sudah berubah itu tidak lagi menunjuk baris "Peta CPL-CPMK" yang dimaksud. Teks jadi nyasar ke sel/baris lain, termasuk kolom sempit -- terlihat sebagai puluhan halaman teks terpotong huruf demi huruf. Pola yang benar sudah dipakai untuk tabel mingguan (trs[31], trs[33], dst -- referensi elemen XML baris asli yang dibekukan di awal, tak ikut bergeser). Terapkan pola sama (helper cell()) untuk blok naratif yang kelewat. Diverifikasi: label & isi sekarang sejajar benar (baris "Peta CPL - CP MK" berisi peta CPMK->CPL, bukan lagi nama dosen pengampu), dokumen turun dari puluhan halaman rusak jadi 2 halaman rapi untuk bagian ini. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
275 lines
10 KiB
Python
275 lines
10 KiB
Python
# -*- coding: utf-8 -*-
|
||
"""
|
||
Generator RPS format resmi UNTAN (standar Dikti, SN Dikti Permendikbud 3/2020).
|
||
|
||
Pendekatan: file template resmi (`templates/rps_untan.docx`) dimuat apa adanya,
|
||
sel-selnya diisi data, dan baris digandakan seperlunya — sehingga tata letak,
|
||
merge sel, dan logo dijamin identik dengan template. Ditambah satu lampiran
|
||
kisi-kisi asesmen (bukti constructive alignment) setelah tabel utama.
|
||
|
||
Peta baris tabel template (39 baris x 18 kolom):
|
||
0 kop (logo | identitas institusi | kode dokumen)
|
||
2/3 label & isian: MK, kode, rumpun, bobot T/P, semester, tgl
|
||
4/5 otorisasi: pengembang, koordinator, kaprodi
|
||
6..11 CPL (header + 5 baris contoh)
|
||
12..14 CPMK (header + 2 baris contoh)
|
||
15..17 Sub-CPMK (header + 2 baris contoh)
|
||
18..27 peta CPL-CPMK, deskripsi, bahan kajian, pustaka U/P, media,
|
||
pengampu, MK syarat
|
||
28..30 header tabel mingguan
|
||
31..37 baris contoh mingguan (31 normal, 33 UTS, 37 UAS)
|
||
38 total bobot
|
||
"""
|
||
import copy
|
||
import io
|
||
import os
|
||
import pathlib
|
||
|
||
from docx import Document
|
||
from docx.enum.text import WD_ALIGN_PARAGRAPH
|
||
from docx.oxml import OxmlElement
|
||
from docx.oxml.ns import qn
|
||
from docx.shared import Pt
|
||
from docx.table import _Row
|
||
|
||
TEMPLATE = pathlib.Path(__file__).resolve().parent / "templates" / "rps_untan.docx"
|
||
|
||
FAKULTAS = os.environ.get("FAKULTAS", "FAKULTAS TEKNIK")
|
||
PRODI = os.environ.get("PRODI", "PROGRAM STUDI INFORMATIKA")
|
||
|
||
|
||
def _set_text(cell, text, align=None, bold=None):
|
||
"""Ganti isi sel; gaya paragraf/tabel template dipertahankan.
|
||
|
||
Beberapa sel template resmi (mis. contoh isian "peta CPL-CPMK") memuat
|
||
tabel kecil bersarang di antara paragraf petunjuknya. w:tc yang berakhir
|
||
dengan w:tbl tanpa w:p setelahnya melanggar skema OOXML — Word
|
||
menganggap dokumennya rusak dan mem-"perbaiki" (merapikan ulang) sendiri
|
||
saat dibuka, yang justru merusak tata letak. Buang tabel bersarang itu
|
||
sekalian saat sel ditimpa teks baru.
|
||
"""
|
||
for tbl in cell._tc.findall(qn("w:tbl")):
|
||
tbl.getparent().remove(tbl)
|
||
for p in cell.paragraphs[1:]:
|
||
p._p.getparent().remove(p._p)
|
||
p = cell.paragraphs[0]
|
||
for r in list(p.runs):
|
||
r._r.getparent().remove(r._r)
|
||
run = p.add_run()
|
||
for i, line in enumerate((text or "").split("\n")):
|
||
if i:
|
||
run.add_break()
|
||
run.add_text(line)
|
||
if bold is not None:
|
||
run.bold = bold
|
||
if align is not None:
|
||
p.alignment = align
|
||
|
||
|
||
def _unique_cells(row):
|
||
seen, out = None, []
|
||
for c in row.cells:
|
||
if c._tc is seen:
|
||
continue
|
||
seen = c._tc
|
||
out.append(c)
|
||
return out
|
||
|
||
|
||
def _clone_after(table, proto_tr, ref_tr):
|
||
"""Salin baris prototipe, kosongkan teksnya, sisipkan setelah ref_tr."""
|
||
new_tr = copy.deepcopy(proto_tr)
|
||
ref_tr.addnext(new_tr)
|
||
row = _Row(new_tr, table)
|
||
for c in _unique_cells(row):
|
||
_set_text(c, "")
|
||
return row
|
||
|
||
|
||
def _fmt(v, suffix=""):
|
||
return f"{v}{suffix}" if v not in (None, "") else ""
|
||
|
||
|
||
def _grid_borders(table):
|
||
"""Beri garis penuh pada tabel (template tidak punya style 'Table Grid')."""
|
||
tbl_pr = table._tbl.tblPr
|
||
borders = OxmlElement("w:tblBorders")
|
||
for sisi in ("top", "left", "bottom", "right", "insideH", "insideV"):
|
||
el = OxmlElement(f"w:{sisi}")
|
||
el.set(qn("w:val"), "single")
|
||
el.set(qn("w:sz"), "4")
|
||
el.set(qn("w:color"), "000000")
|
||
borders.append(el)
|
||
tbl_pr.append(borders)
|
||
|
||
|
||
def build_rps(d: dict) -> bytes:
|
||
doc = Document(str(TEMPLATE))
|
||
t = doc.tables[0]
|
||
ident = d.get("identitas", {})
|
||
|
||
# ── kop & identitas ──
|
||
_set_text(
|
||
t.cell(0, 3),
|
||
f"UNIVERSITAS TANJUNGPURA\n{FAKULTAS}\n{PRODI}",
|
||
align=WD_ALIGN_PARAGRAPH.CENTER, bold=True,
|
||
)
|
||
_set_text(t.cell(0, 16), "Kode Dokumen\n" + (ident.get("kode_dokumen") or "—"))
|
||
|
||
_set_text(t.cell(3, 0), ident.get("nama", ""), bold=True)
|
||
_set_text(t.cell(3, 5), ident.get("kode", ""))
|
||
_set_text(t.cell(3, 7), ident.get("rumpun", ""))
|
||
_set_text(t.cell(3, 10), "T = " + (ident.get("sks_teori") or "-"))
|
||
_set_text(t.cell(3, 11), "P = " + (ident.get("sks_praktikum") or "-"))
|
||
_set_text(t.cell(3, 13), ident.get("semester", ""))
|
||
_set_text(t.cell(3, 15), ident.get("tgl_penyusunan", ""))
|
||
|
||
_set_text(t.cell(5, 5), ident.get("pengembang") or ident.get("pengampu", ""))
|
||
_set_text(t.cell(5, 10), ident.get("koordinator", ""))
|
||
_set_text(t.cell(5, 13), ident.get("kaprodi", ""))
|
||
|
||
trs = [r._tr for r in t.rows] # indeks baris template asli
|
||
|
||
def cell(idx, col):
|
||
"""Ambil sel dari baris ASLI template lewat trs[idx] (referensi XML
|
||
beku), bukan t.cell(idx, col) yang ikut bergeser tiap kali baris
|
||
CPL/CPMK/Sub-CPMK digandakan/dibuang di atasnya — pakai t.cell()
|
||
langsung untuk baris di bawah blok itu akan salah sasaran."""
|
||
return _Row(trs[idx], t).cells[col]
|
||
|
||
def isi_daftar(proto_idx, hapus_idx, items, kolom_kode=2, kolom_isi=5):
|
||
"""Isi blok daftar (CPL/CPMK/Sub-CPMK): prototipe digandakan per item."""
|
||
proto = trs[proto_idx]
|
||
ref = proto
|
||
for kode, isi in items:
|
||
row = _clone_after(t, proto, ref)
|
||
_set_text(row.cells[kolom_kode], kode, bold=True)
|
||
_set_text(row.cells[kolom_isi], isi)
|
||
ref = row._tr
|
||
for i in hapus_idx:
|
||
trs[i].getparent().remove(trs[i])
|
||
proto.getparent().remove(proto)
|
||
|
||
# ── CPL / CPMK / Sub-CPMK (baris digandakan sesuai data) ──
|
||
isi_daftar(7, [8, 9, 10, 11], [(c["kode"], c["desk"]) for c in d.get("cpl", [])])
|
||
isi_daftar(13, [14], [(c["kode"], c["desk"]) for c in d.get("cpmk", [])])
|
||
isi_daftar(16, [17], [(s["kode"], s["uraian"]) for s in d.get("sub", [])])
|
||
|
||
# ── peta CPL–CPMK & blok naratif ──
|
||
peta_txt = "\n".join(
|
||
f"{c['kode']} → {', '.join(c.get('cpl', [])) or '-'}"
|
||
for c in d.get("cpmk", [])
|
||
)
|
||
_set_text(cell(18, 2), peta_txt)
|
||
_set_text(cell(19, 2), d.get("deskripsi", ""))
|
||
_set_text(cell(20, 2), d.get("bahan_kajian", ""))
|
||
|
||
utama = d.get("referensi", [])
|
||
pendukung = d.get("referensi_pendukung", [])
|
||
_set_text(cell(22, 2), "\n".join(f"{i}. {r}" for i, r in enumerate(utama, 1)))
|
||
_set_text(cell(24, 2), "\n".join(f"{i}. {r}" for i, r in enumerate(pendukung, 1)))
|
||
_set_text(cell(25, 2), d.get("media", ""))
|
||
_set_text(cell(26, 2), ident.get("pengampu", ""))
|
||
_set_text(cell(27, 2), ident.get("prasyarat") or "—")
|
||
|
||
# ── tabel mingguan ──
|
||
proto_norm, proto_uts, proto_uas = trs[31], trs[33], trs[37]
|
||
total = 0.0
|
||
ref = trs[30] # sisipkan setelah baris nomor kolom (1)(2)...(9)
|
||
for m in d.get("minggu", []):
|
||
w = m.get("ke")
|
||
try:
|
||
total += float(m.get("bobot") or 0)
|
||
except (TypeError, ValueError):
|
||
pass
|
||
if w in (8, 16):
|
||
row = _clone_after(t, proto_uts if w == 8 else proto_uas, ref)
|
||
cells = _unique_cells(row)
|
||
_set_text(cells[0], str(w), align=WD_ALIGN_PARAGRAPH.CENTER, bold=True)
|
||
nama_ujian = "Ujian Tengah Semester (UTS)" if w == 8 else "Ujian Akhir Semester (UAS)"
|
||
gab = [nama_ujian]
|
||
if m.get("sub"):
|
||
gab.append(f"Cakupan: {m['sub']}")
|
||
if m.get("indikator"):
|
||
gab.append(f"Indikator: {m['indikator']}")
|
||
if m.get("metode"):
|
||
gab.append(f"Bentuk: {m['metode']}")
|
||
_set_text(cells[1], "\n".join(gab))
|
||
_set_text(cells[2], _fmt(m.get("bobot")), align=WD_ALIGN_PARAGRAPH.CENTER)
|
||
else:
|
||
row = _clone_after(t, proto_norm, ref)
|
||
cells = _unique_cells(row)
|
||
_set_text(cells[0], str(w), align=WD_ALIGN_PARAGRAPH.CENTER)
|
||
_set_text(cells[1], m.get("sub", ""))
|
||
_set_text(cells[2], m.get("indikator", ""))
|
||
_set_text(cells[3], m.get("kriteria", ""))
|
||
_set_text(cells[4], m.get("metode", "")) # tatap muka
|
||
_set_text(cells[5], m.get("daring", ""))
|
||
_set_text(cells[6], m.get("kegiatan", ""))
|
||
_set_text(cells[7], m.get("materi", ""))
|
||
_set_text(cells[8], _fmt(m.get("bobot")), align=WD_ALIGN_PARAGRAPH.CENTER)
|
||
ref = row._tr
|
||
|
||
for i in (31, 32, 33, 34, 35, 36, 37): # buang seluruh baris contoh
|
||
trs[i].getparent().remove(trs[i])
|
||
|
||
_set_text(
|
||
_unique_cells(_Row(trs[38], t))[-1],
|
||
f"{total:g}", align=WD_ALIGN_PARAGRAPH.CENTER, bold=True,
|
||
)
|
||
|
||
# ── buang paragraf petunjuk pengisian (catatan SN Dikti) setelah tabel ──
|
||
body = doc.element.body
|
||
tbl_seen = False
|
||
for el in list(body):
|
||
if el.tag == qn("w:tbl"):
|
||
tbl_seen = True
|
||
continue
|
||
if tbl_seen and el.tag == qn("w:p"):
|
||
body.remove(el)
|
||
|
||
# ── lampiran kisi-kisi asesmen ──
|
||
doc.add_page_break()
|
||
p = doc.add_paragraph()
|
||
r = p.add_run("LAMPIRAN — KISI-KISI ASESMEN (Komponen Penilaian × Sub-CPMK)")
|
||
r.bold = True
|
||
r.font.size = Pt(11)
|
||
|
||
komponen = [
|
||
k for k in d.get("komponen", [])
|
||
if k.get("subs") or str(k.get("bobot") or "").strip() not in ("", "0", "0.0")
|
||
]
|
||
tk = doc.add_table(rows=1 + max(len(komponen), 1), cols=4)
|
||
_grid_borders(tk)
|
||
for ci, judul in enumerate(
|
||
["Komponen", "Sub-CPMK yang Dinilai", "Porsi per Sub-CPMK (%)", "Bobot (%)"]
|
||
):
|
||
_set_text(tk.cell(0, ci), judul, bold=True, align=WD_ALIGN_PARAGRAPH.CENTER)
|
||
for i, k in enumerate(komponen, 1):
|
||
subs_k = k.get("subs", [])
|
||
try:
|
||
bobot = float(k.get("bobot") or 0)
|
||
except (TypeError, ValueError):
|
||
bobot = 0.0
|
||
_set_text(tk.cell(i, 0), k.get("nama", ""))
|
||
_set_text(tk.cell(i, 1), ", ".join(subs_k) or "—")
|
||
_set_text(
|
||
tk.cell(i, 2),
|
||
f"{bobot / len(subs_k):.1f} (bagi rata)" if subs_k else "—",
|
||
align=WD_ALIGN_PARAGRAPH.CENTER,
|
||
)
|
||
_set_text(tk.cell(i, 3), str(k.get("bobot", "")), align=WD_ALIGN_PARAGRAPH.CENTER)
|
||
|
||
p = doc.add_paragraph()
|
||
r = p.add_run(
|
||
"Porsi per Sub-CPMK dihitung bagi rata dari bobot komponen (kebijakan "
|
||
"default prodi); penyesuaian porsi per butir soal dicatat pada dokumen "
|
||
"portofolio mata kuliah."
|
||
)
|
||
r.italic = True
|
||
r.font.size = Pt(9)
|
||
|
||
buf = io.BytesIO()
|
||
doc.save(buf)
|
||
return buf.getvalue()
|