laporan-generator 2.6.2 → 2.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -2,11 +2,19 @@
2
2
  """report-doctor.py: Audit komprehensif kesehatan dan integritas proyek laporan."""
3
3
 
4
4
  import glob
5
+ import json
5
6
  import os
6
7
  import re
7
8
  import shutil
8
9
  import sys
9
10
 
11
+ if sys.platform == "win32":
12
+ try:
13
+ sys.stdout.reconfigure(encoding="utf-8", errors="replace")
14
+ sys.stderr.reconfigure(encoding="utf-8", errors="replace")
15
+ except Exception:
16
+ pass
17
+
10
18
  GREEN = "\033[0;32m"
11
19
  BLUE = "\033[0;34m"
12
20
  CYAN = "\033[0;36m"
@@ -98,101 +106,166 @@ def check_style_anomalies(markdown_files):
98
106
 
99
107
 
100
108
  def main():
101
- root = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
102
- os.chdir(root)
103
-
104
- print(f"{CYAN}{BOLD}")
105
- print(" ========================================================")
106
- print(" LAPORAN GENERATOR - DOCTOR & AUDIT SUITE ")
107
- print(" ========================================================")
108
- print(f"{NC}")
109
+ json_mode = "--json" in sys.argv
110
+ pos_args = [a for a in sys.argv[1:] if not a.startswith("-")]
111
+
112
+ target_dir = os.getcwd()
113
+ if pos_args:
114
+ target_dir = os.path.abspath(pos_args[0])
115
+ elif not (os.path.isfile(os.path.join(target_dir, "metadata.yml")) or os.path.isdir(os.path.join(target_dir, "chapters"))):
116
+ pkg_root = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
117
+ if os.path.isfile(os.path.join(pkg_root, "metadata.yml")):
118
+ target_dir = pkg_root
119
+
120
+ os.chdir(target_dir)
121
+
122
+ if not json_mode:
123
+ print(f"{CYAN}{BOLD}")
124
+ print(" ========================================================")
125
+ print(" LAPORAN GENERATOR - DOCTOR & AUDIT SUITE ")
126
+ print(" ========================================================")
127
+ print(f"{NC}")
109
128
 
110
129
  errors = 0
111
130
  warnings = 0
112
131
 
113
132
  # 1. Dependensi
114
- print(f" {BLUE}{BOLD}[1/4] Audit Toolchain & Dependensi Sistem{NC}")
133
+ if not json_mode:
134
+ print(f" {BLUE}{BOLD}[1/4] Audit Toolchain & Dependensi Sistem{NC}")
115
135
  deps, has_im = check_dependencies()
136
+ dep_list = []
116
137
  for cmd, name, req, found in deps:
117
138
  if "ImageMagick" in name:
118
139
  continue
119
- if found:
120
- print(f" {GREEN}[OK]{NC} {name:<26}: Terpasang ({cmd})")
121
- else:
122
- if req:
123
- print(f" {RED}[FAIL]{NC} {name:<24}: TIDAK DITEMUKAN (Wajib)")
124
- errors += 1
140
+ dep_list.append({"command": cmd, "name": name, "required": req, "found": found})
141
+ if not json_mode:
142
+ if found:
143
+ print(f" {GREEN}[OK]{NC} {name:<26}: Terpasang ({cmd})")
125
144
  else:
126
- print(f" {YELLOW}[INFO]{NC} {name:<24}: Tidak ditemukan (Opsional)")
145
+ if req:
146
+ print(f" {RED}[FAIL]{NC} {name:<24}: TIDAK DITEMUKAN (Wajib)")
147
+ else:
148
+ print(f" {YELLOW}[INFO]{NC} {name:<24}: Tidak ditemukan (Opsional)")
149
+ if not found and req:
150
+ errors += 1
127
151
 
128
- if has_im:
129
- print(f" {GREEN}[OK]{NC} {'ImageMagick':<26}: Terpasang")
130
- else:
131
- print(f" {YELLOW}[INFO]{NC} {'ImageMagick':<24}: Tidak ditemukan (Opsional untuk kompresi gambar)")
132
- print("")
152
+ dep_list.append({"command": "magick/convert", "name": "ImageMagick", "required": False, "found": has_im})
153
+ if not json_mode:
154
+ if has_im:
155
+ print(f" {GREEN}[OK]{NC} {'ImageMagick':<26}: Terpasang")
156
+ else:
157
+ print(f" {YELLOW}[INFO]{NC} {'ImageMagick':<24}: Tidak ditemukan (Opsional untuk kompresi gambar)")
158
+ print("")
133
159
 
134
160
  # 2. Berkas & Struktur
135
- print(f" {BLUE}{BOLD}[2/4] Audit Struktur Direktori & Berkas Wajib{NC}")
161
+ if not json_mode:
162
+ print(f" {BLUE}{BOLD}[2/4] Audit Struktur Direktori & Berkas Wajib{NC}")
136
163
  req_files = [
137
164
  "metadata.yml", "template.typ", "reference.docx",
138
165
  "references.bib", "docx.lua", "cover.md"
139
166
  ]
167
+ file_status = []
140
168
  for rf in req_files:
141
- if os.path.isfile(rf):
142
- print(f" {GREEN}[OK]{NC} Berkas {rf}")
143
- else:
144
- print(f" {RED}[FAIL]{NC} Berkas {rf} TIDAK DITEMUKAN")
169
+ exists = os.path.isfile(rf)
170
+ file_status.append({"file": rf, "found": exists})
171
+ if not json_mode:
172
+ if exists:
173
+ print(f" {GREEN}[OK]{NC} Berkas {rf}")
174
+ else:
175
+ print(f" {RED}[FAIL]{NC} Berkas {rf} TIDAK DITEMUKAN")
176
+ if not exists:
145
177
  errors += 1
146
178
 
147
179
  chapter_files = sorted(glob.glob("chapters/*.md"))
148
- if chapter_files:
149
- print(f" {GREEN}[OK]{NC} Direktori chapters/ ({len(chapter_files)} bab ditemukan)")
150
- else:
151
- print(f" {RED}[FAIL]{NC} Tidak ada berkas markdown di chapters/")
180
+ if not json_mode:
181
+ if chapter_files:
182
+ print(f" {GREEN}[OK]{NC} Direktori chapters/ ({len(chapter_files)} bab ditemukan)")
183
+ else:
184
+ print(f" {RED}[FAIL]{NC} Tidak ada berkas markdown di chapters/")
185
+ if not chapter_files:
152
186
  errors += 1
153
- print("")
187
+ if not json_mode:
188
+ print("")
154
189
 
155
190
  # 3. Sitasi & Referensi
156
- print(f" {BLUE}{BOLD}[3/4] Audit Sitasi & Integritas Bibliografi{NC}")
191
+ if not json_mode:
192
+ print(f" {BLUE}{BOLD}[3/4] Audit Sitasi & Integritas Bibliografi{NC}")
157
193
  bib_keys, cited_keys, missing_keys, orphan_keys = check_citations("references.bib", chapter_files)
158
- print(f" * Total entri .bib : {len(bib_keys)}")
159
- print(f" * Total sitasi di teks : {len(cited_keys)}")
194
+ if not json_mode:
195
+ print(f" * Total entri .bib : {len(bib_keys)}")
196
+ print(f" * Total sitasi di teks : {len(cited_keys)}")
160
197
 
161
198
  if missing_keys:
162
199
  for k, locs in missing_keys.items():
163
200
  loc_str = ", ".join([f"{f}:{l}" for f, l in locs])
164
- print(f" {RED}[ERROR]{NC} Sitasi '@{k}' TIDAK DITEMUKAN di references.bib ({loc_str})")
201
+ if not json_mode:
202
+ print(f" {RED}[ERROR]{NC} Sitasi '@{k}' TIDAK DITEMUKAN di references.bib ({loc_str})")
165
203
  errors += 1
166
- else:
204
+ elif not json_mode:
167
205
  print(f" {GREEN}[OK]{NC} Semua sitasi dalam teks valid terdaftar di references.bib")
168
206
 
169
207
  if orphan_keys:
170
- print(f" {YELLOW}[WARN]{NC} {len(orphan_keys)} entri di references.bib belum disitir dalam teks:")
171
- for ok in list(orphan_keys)[:5]:
172
- print(f" - @{ok}")
173
- if len(orphan_keys) > 5:
174
- print(f" - ... dan {len(orphan_keys) - 5} lainnya")
208
+ if not json_mode:
209
+ print(f" {YELLOW}[WARN]{NC} {len(orphan_keys)} entri di references.bib belum disitir dalam teks:")
210
+ for ok in list(orphan_keys)[:5]:
211
+ print(f" - @{ok}")
212
+ if len(orphan_keys) > 5:
213
+ print(f" - ... dan {len(orphan_keys) - 5} lainnya")
175
214
  warnings += 1
176
- print("")
215
+ if not json_mode:
216
+ print("")
177
217
 
178
218
  # 4. Gambar & Format Teks
179
- print(f" {BLUE}{BOLD}[4/4] Audit Media Citra & Anomali Tipografi{NC}")
219
+ if not json_mode:
220
+ print(f" {BLUE}{BOLD}[4/4] Audit Media Citra & Anomali Tipografi{NC}")
180
221
  total_imgs, broken_imgs = check_images(chapter_files)
181
222
  if broken_imgs:
182
223
  for p, f, l in broken_imgs:
183
- print(f" {RED}[ERROR]{NC} Berkas gambar tidak ditemukan: '{p}' (di {f}:{l})")
224
+ if not json_mode:
225
+ print(f" {RED}[ERROR]{NC} Berkas gambar tidak ditemukan: '{p}' (di {f}:{l})")
184
226
  errors += 1
185
- else:
227
+ elif not json_mode:
186
228
  print(f" {GREEN}[OK]{NC} Semua tautan gambar ({total_imgs} gambar) valid dan ada di disk")
187
229
 
188
230
  anomalies = check_style_anomalies(chapter_files)
189
231
  if anomalies:
190
232
  for f, l, msg in anomalies:
191
- print(f" {YELLOW}[WARN]{NC} {f}:{l} - {msg}")
233
+ if not json_mode:
234
+ print(f" {YELLOW}[WARN]{NC} {f}:{l} - {msg}")
192
235
  warnings += 1
193
- else:
236
+ elif not json_mode:
194
237
  print(f" {GREEN}[OK]{NC} Bebas dari manual heading numbering dan box-drawing characters")
195
- print("")
238
+ if not json_mode:
239
+ print("")
240
+
241
+ score = 100 if (errors == 0 and warnings == 0) else (95 if errors == 0 else max(0, 90 - errors * 15 - warnings * 5))
242
+ status_label = "HEALTHY" if errors == 0 and warnings == 0 else ("WARNING" if errors == 0 else "ERROR")
243
+
244
+ if json_mode:
245
+ result = {
246
+ "status": status_label,
247
+ "score": score,
248
+ "errors_count": errors,
249
+ "warnings_count": warnings,
250
+ "target_dir": os.getcwd(),
251
+ "dependencies": dep_list,
252
+ "required_files": file_status,
253
+ "chapters_count": len(chapter_files),
254
+ "chapters": [os.path.basename(c) for c in chapter_files],
255
+ "citations": {
256
+ "bib_count": len(bib_keys),
257
+ "cited_count": len(cited_keys),
258
+ "missing_keys": {k: [f"{f}:{l}" for f, l in locs] for k, locs in missing_keys.items()},
259
+ "orphan_keys": list(orphan_keys),
260
+ },
261
+ "images": {
262
+ "total": total_imgs,
263
+ "broken": [{"path": p, "file": f, "line": l} for p, f, l in broken_imgs],
264
+ },
265
+ "anomalies": [{"file": f, "line": l, "message": msg} for f, l, msg in anomalies],
266
+ }
267
+ print(json.dumps(result, indent=2))
268
+ return 1 if errors > 0 else 0
196
269
 
197
270
  # Skor Kesehatan
198
271
  print(" ========================================================")
@@ -2,10 +2,18 @@
2
2
  """report-stats.py: Menghitung statistik komprehensif dokumen laporan akademik."""
3
3
 
4
4
  import glob
5
+ import json
5
6
  import os
6
7
  import re
7
8
  import sys
8
9
 
10
+ if sys.platform == "win32":
11
+ try:
12
+ sys.stdout.reconfigure(encoding="utf-8", errors="replace")
13
+ sys.stderr.reconfigure(encoding="utf-8", errors="replace")
14
+ except Exception:
15
+ pass
16
+
9
17
  GREEN = "\033[0;32m"
10
18
  BLUE = "\033[0;34m"
11
19
  CYAN = "\033[0;36m"
@@ -48,8 +56,18 @@ def count_file_stats(filepath):
48
56
 
49
57
 
50
58
  def main():
51
- root = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
52
- os.chdir(root)
59
+ json_mode = "--json" in sys.argv
60
+ pos_args = [a for a in sys.argv[1:] if not a.startswith("-")]
61
+
62
+ target_dir = os.getcwd()
63
+ if pos_args:
64
+ target_dir = os.path.abspath(pos_args[0])
65
+ elif not (os.path.isfile(os.path.join(target_dir, "metadata.yml")) or os.path.isdir(os.path.join(target_dir, "chapters"))):
66
+ pkg_root = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
67
+ if os.path.isfile(os.path.join(pkg_root, "metadata.yml")):
68
+ target_dir = pkg_root
69
+
70
+ os.chdir(target_dir)
53
71
 
54
72
  title = "Laporan Akademik"
55
73
  preset = "standard"
@@ -109,6 +127,39 @@ def main():
109
127
  sz = os.path.getsize("Laporan.docx")
110
128
  docx_size = f"{sz / 1024:.1f} KB"
111
129
 
130
+ if json_mode:
131
+ result = {
132
+ "title": title,
133
+ "preset": preset,
134
+ "target_dir": os.getcwd(),
135
+ "chapters_count": len(chapter_files),
136
+ "total_words": total_words,
137
+ "total_chars": total_chars,
138
+ "est_pages": est_pages,
139
+ "read_mins": read_mins,
140
+ "headings": {
141
+ "h1": total_h1,
142
+ "h2": total_h2,
143
+ "h3": total_h3,
144
+ },
145
+ "elements": {
146
+ "images": total_images,
147
+ "tables": total_tables,
148
+ "equations": total_equations,
149
+ },
150
+ "citations": {
151
+ "unique_cited": len(all_citations),
152
+ "bib_entries": bib_count,
153
+ },
154
+ "compiled_files": {
155
+ "pdf": pdf_size,
156
+ "docx": docx_size,
157
+ },
158
+ "file_breakdown": [{"filename": f, "words": c} for f, c in file_breakdown],
159
+ }
160
+ print(json.dumps(result, indent=2))
161
+ return
162
+
112
163
  print(f"{CYAN}{BOLD}")
113
164
  print(" ========================================================")
114
165
  print(" LAPORAN GENERATOR - STATISTIK DOKUMEN ")
@@ -1,7 +1,9 @@
1
1
  #!/usr/bin/env python3
2
2
  """test_scripts.py: Automated unit tests for Laporan Generator Python helper scripts."""
3
3
 
4
+ import json
4
5
  import os
6
+ import subprocess
5
7
  import sys
6
8
  import unittest
7
9
 
@@ -36,16 +38,39 @@ class TestScanPreset(unittest.TestCase):
36
38
  self.assertIn("UNIVERSITAS INDONESIA", inst)
37
39
 
38
40
 
41
+ def get_presets_dir():
42
+ for c in [
43
+ os.path.join(ROOT_DIR, "presets"),
44
+ os.path.join(ROOT_DIR, "resources", "presets"),
45
+ os.path.join(ROOT_DIR, "..", "..", "..", "presets"),
46
+ ]:
47
+ if os.path.isdir(c):
48
+ return os.path.abspath(c)
49
+ return os.path.join(ROOT_DIR, "presets")
50
+
51
+
52
+ def get_project_root():
53
+ for c in [
54
+ ROOT_DIR,
55
+ os.path.join(ROOT_DIR, "resources"),
56
+ os.path.join(ROOT_DIR, "..", "..", ".."),
57
+ ]:
58
+ if os.path.isfile(os.path.join(c, "metadata.yml")):
59
+ return os.path.abspath(c)
60
+ return ROOT_DIR
61
+
62
+
39
63
  class TestValidatePreset(unittest.TestCase):
40
64
  def test_parse_simple_yaml(self):
41
- test_yaml = os.path.join(ROOT_DIR, "presets", "standard.yml")
65
+ presets_dir = get_presets_dir()
66
+ test_yaml = os.path.join(presets_dir, "standard.yml")
42
67
  self.assertTrue(os.path.exists(test_yaml))
43
68
  data = validate_preset.parse_simple_yaml(test_yaml)
44
69
  self.assertEqual(data.get("preset_id"), "standard")
45
70
  self.assertEqual(data.get("margin_top"), "2cm")
46
71
 
47
72
  def test_validate_all_presets(self):
48
- presets_dir = os.path.join(ROOT_DIR, "presets")
73
+ presets_dir = get_presets_dir()
49
74
  for f in os.listdir(presets_dir):
50
75
  if f.endswith(".yml"):
51
76
  path = os.path.join(presets_dir, f)
@@ -123,6 +148,42 @@ class TestFinalizeDocx(unittest.TestCase):
123
148
  self.assertIn('w:left="720"', res)
124
149
 
125
150
 
151
+ report_stats = __import__("report-stats")
152
+
153
+
154
+ class TestReportStats(unittest.TestCase):
155
+ def test_count_file_stats_structure(self):
156
+ sample_md = os.path.join(get_project_root(), "cover.md")
157
+ if os.path.exists(sample_md):
158
+ stats = report_stats.count_file_stats(sample_md)
159
+ self.assertIsInstance(stats, dict)
160
+ self.assertIsInstance(stats["words"], int)
161
+ self.assertIsInstance(stats["chars"], int)
162
+ self.assertGreaterEqual(stats["words"], 0)
163
+
164
+ def test_json_stats_execution(self):
165
+ proj_root = get_project_root()
166
+ cmd = [sys.executable, os.path.join(SCRIPTS_DIR, "report-stats.py"), proj_root, "--json"]
167
+ proc = subprocess.run(cmd, capture_output=True, text=True, cwd=proj_root)
168
+ self.assertEqual(proc.returncode, 0)
169
+ data = json.loads(proc.stdout)
170
+ self.assertIn("total_words", data)
171
+ self.assertIn("preset", data)
172
+ self.assertIn("target_dir", data)
173
+
174
+
175
+ class TestReportDoctor(unittest.TestCase):
176
+ def test_json_doctor_execution(self):
177
+ proj_root = get_project_root()
178
+ cmd = [sys.executable, os.path.join(SCRIPTS_DIR, "report-doctor.py"), proj_root, "--json"]
179
+ proc = subprocess.run(cmd, capture_output=True, text=True, cwd=proj_root)
180
+ data = json.loads(proc.stdout)
181
+ self.assertIn("status", data)
182
+ self.assertIn("dependencies", data)
183
+ self.assertIn("required_files", data)
184
+ self.assertIn("citations", data)
185
+
186
+
126
187
  if __name__ == "__main__":
127
188
  unittest.main(verbosity=2)
128
189
 
@@ -15,8 +15,17 @@ NC = "\033[0m"
15
15
 
16
16
 
17
17
  def main():
18
- root = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
19
- os.chdir(root)
18
+ pos_args = [a for a in sys.argv[1:] if not a.startswith("-")]
19
+
20
+ target_dir = os.getcwd()
21
+ if pos_args:
22
+ target_dir = os.path.abspath(pos_args[0])
23
+ elif not (os.path.isfile(os.path.join(target_dir, "metadata.yml")) or os.path.isdir(os.path.join(target_dir, "chapters"))):
24
+ pkg_root = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
25
+ if os.path.isfile(os.path.join(pkg_root, "metadata.yml")):
26
+ target_dir = pkg_root
27
+
28
+ os.chdir(target_dir)
20
29
 
21
30
  print(f"{CYAN}{BOLD}")
22
31
  print(" ========================================================")
@@ -129,6 +129,133 @@ def process(doc):
129
129
  return "".join(out), changed
130
130
 
131
131
 
132
+ def get_tab_pos(doc):
133
+ sz = re.search(r'<w:pgSz\b[^>]*w:w="(\d+)"', doc)
134
+ mar_left = re.search(r'<w:pgMar\b[^>]*w:left="(\d+)"', doc)
135
+ mar_right = re.search(r'<w:pgMar\b[^>]*w:right="(\d+)"', doc)
136
+ if sz and mar_left and mar_right:
137
+ try:
138
+ w = int(sz.group(1))
139
+ left = int(mar_left.group(1))
140
+ right = int(mar_right.group(1))
141
+ pos = w - left - right
142
+ if pos > 1000:
143
+ return pos
144
+ except (ValueError, TypeError):
145
+ pass
146
+ return 9072
147
+
148
+
149
+ def fix_daftar_isi_title(doc):
150
+ pattern = re.compile(
151
+ r'(<w:p\b[^>]*><w:pPr>(?:(?!</w:p>).)*?<w:pStyle\s+w:val=["\x27])Heading1(["\x27](?:(?!</w:p>).)*?<w:t[^>]*>\s*DAFTAR ISI\s*</w:t>(?:(?!</w:p>).)*?</w:p>)',
152
+ re.S,
153
+ )
154
+ return pattern.sub(r"\g<1>TOCHeading\g<2>", doc)
155
+
156
+
157
+ def fix_toc_styles(styles, tab_pos):
158
+ tnr = '<w:rFonts w:ascii="Times New Roman" w:hAnsi="Times New Roman" w:cs="Times New Roman"/>'
159
+ defs = {
160
+ "TOCHeading": f"""<w:style w:type="paragraph" w:styleId="TOCHeading">
161
+ <w:name w:val="TOC Heading"/>
162
+ <w:basedOn w:val="Normal"/>
163
+ <w:next w:val="Normal"/>
164
+ <w:uiPriority w:val="39"/>
165
+ <w:qFormat/>
166
+ <w:pPr>
167
+ <w:spacing w:before="360" w:after="360" w:line="240" w:lineRule="auto"/>
168
+ <w:jc w:val="center"/>
169
+ <w:outlineLvl w:val="9"/>
170
+ </w:pPr>
171
+ <w:rPr>
172
+ {tnr}
173
+ <w:color w:val="000000"/>
174
+ <w:sz w:val="28"/>
175
+ <w:szCs w:val="28"/>
176
+ <w:b/>
177
+ <w:bCs/>
178
+ </w:rPr>
179
+ </w:style>""",
180
+ "TOC1": f"""<w:style w:type="paragraph" w:styleId="TOC1">
181
+ <w:name w:val="toc 1"/>
182
+ <w:basedOn w:val="Normal"/>
183
+ <w:next w:val="Normal"/>
184
+ <w:qFormat/>
185
+ <w:pPr>
186
+ <w:tabs>
187
+ <w:tab w:val="right" w:leader="dot" w:pos="{tab_pos}"/>
188
+ </w:tabs>
189
+ <w:spacing w:before="120" w:after="40" w:line="260" w:lineRule="auto"/>
190
+ <w:ind w:left="0" w:right="0"/>
191
+ <w:jc w:val="left"/>
192
+ </w:pPr>
193
+ <w:rPr>
194
+ {tnr}
195
+ <w:color w:val="000000"/>
196
+ <w:sz w:val="24"/>
197
+ <w:szCs w:val="24"/>
198
+ <w:b/>
199
+ <w:bCs/>
200
+ </w:rPr>
201
+ </w:style>""",
202
+ "TOC2": f"""<w:style w:type="paragraph" w:styleId="TOC2">
203
+ <w:name w:val="toc 2"/>
204
+ <w:basedOn w:val="TOC1"/>
205
+ <w:next w:val="Normal"/>
206
+ <w:qFormat/>
207
+ <w:pPr>
208
+ <w:tabs>
209
+ <w:tab w:val="right" w:leader="dot" w:pos="{tab_pos}"/>
210
+ </w:tabs>
211
+ <w:spacing w:before="20" w:after="20" w:line="240" w:lineRule="auto"/>
212
+ <w:ind w:left="360" w:right="0"/>
213
+ <w:jc w:val="left"/>
214
+ </w:pPr>
215
+ <w:rPr>
216
+ {tnr}
217
+ <w:color w:val="000000"/>
218
+ <w:sz w:val="24"/>
219
+ <w:szCs w:val="24"/>
220
+ <w:b w:val="0"/>
221
+ <w:bCs w:val="0"/>
222
+ </w:rPr>
223
+ </w:style>""",
224
+ "TOC3": f"""<w:style w:type="paragraph" w:styleId="TOC3">
225
+ <w:name w:val="toc 3"/>
226
+ <w:basedOn w:val="TOC2"/>
227
+ <w:next w:val="Normal"/>
228
+ <w:qFormat/>
229
+ <w:pPr>
230
+ <w:tabs>
231
+ <w:tab w:val="right" w:leader="dot" w:pos="{tab_pos}"/>
232
+ </w:tabs>
233
+ <w:spacing w:before="0" w:after="0" w:line="240" w:lineRule="auto"/>
234
+ <w:ind w:left="720" w:right="0"/>
235
+ <w:jc w:val="left"/>
236
+ </w:pPr>
237
+ <w:rPr>
238
+ {tnr}
239
+ <w:color w:val="000000"/>
240
+ <w:sz w:val="24"/>
241
+ <w:szCs w:val="24"/>
242
+ <w:b w:val="0"/>
243
+ <w:bCs w:val="0"/>
244
+ </w:rPr>
245
+ </w:style>""",
246
+ }
247
+ missing = []
248
+ for sid, block in defs.items():
249
+ pattern = re.compile(rf'<w:style\b[^>]*w:styleId="{sid}"[^>]*>.*?</w:style>', re.S)
250
+ if pattern.search(styles):
251
+ styles = pattern.sub(block, styles)
252
+ else:
253
+ missing.append(block)
254
+ if missing:
255
+ styles = styles.replace("</w:styles>", "".join(missing) + "</w:styles>")
256
+ return styles
257
+
258
+
132
259
  def main():
133
260
  src = sys.argv[1]
134
261
  dst = sys.argv[2]
@@ -138,14 +265,26 @@ def main():
138
265
  items = {n: zin.read(n) for n in names}
139
266
 
140
267
  doc = items["word/document.xml"].decode("utf-8")
268
+ tab_pos = get_tab_pos(doc)
269
+ doc = fix_daftar_isi_title(doc)
270
+ # Sesuaikan tab leader pada cached TOC entries agar tepat di margin kanan
271
+ doc = re.sub(
272
+ r'(<w:pStyle w:val="TOC[123]"/>.*?<w:tab w:val="right" w:leader="dot" w:pos=)"\d+"',
273
+ rf'\g<1>"{tab_pos}"',
274
+ doc,
275
+ )
141
276
  new_doc, changed = process(doc)
142
277
  items["word/document.xml"] = new_doc.encode("utf-8")
143
278
 
279
+ if "word/styles.xml" in items:
280
+ styles = items["word/styles.xml"].decode("utf-8")
281
+ items["word/styles.xml"] = fix_toc_styles(styles, tab_pos).encode("utf-8")
282
+
144
283
  with zipfile.ZipFile(dst, "w", zipfile.ZIP_DEFLATED) as zout:
145
284
  for n in items:
146
285
  zout.writestr(n, items[n])
147
286
 
148
- print(f"[OK] finalize-docx: {changed} sectPr disesuaikan -> {dst}")
287
+ print(f"[OK] finalize-docx: {changed} sectPr disesuaikan (tab={tab_pos}) -> {dst}")
149
288
 
150
289
 
151
290
  if __name__ == "__main__":
@@ -329,6 +329,26 @@ STYLES = {
329
329
  <w:szCs w:val="24"/>
330
330
  <w:i/>
331
331
  </w:rPr>
332
+ </w:style>""",
333
+ "TOCHeading": f"""<w:style w:type="paragraph" w:styleId="TOCHeading">
334
+ <w:name w:val="TOC Heading"/>
335
+ <w:basedOn w:val="Normal"/>
336
+ <w:next w:val="Normal"/>
337
+ <w:uiPriority w:val="39"/>
338
+ <w:qFormat/>
339
+ <w:pPr>
340
+ <w:spacing w:before="360" w:after="360" w:line="240" w:lineRule="auto"/>
341
+ <w:jc w:val="center"/>
342
+ <w:outlineLvl w:val="9"/>
343
+ </w:pPr>
344
+ <w:rPr>
345
+ {TNR}
346
+ <w:color w:val="000000"/>
347
+ <w:sz w:val="28"/>
348
+ <w:szCs w:val="28"/>
349
+ <w:b/>
350
+ <w:bCs/>
351
+ </w:rPr>
332
352
  </w:style>""",
333
353
  "TOC1": f"""<w:style w:type="paragraph" w:styleId="TOC1">
334
354
  <w:name w:val="toc 1"/>
@@ -336,7 +356,11 @@ STYLES = {
336
356
  <w:next w:val="Normal"/>
337
357
  <w:qFormat/>
338
358
  <w:pPr>
339
- <w:spacing w:before="60" w:after="20" w:line="260" w:lineRule="auto"/>
359
+ <w:tabs>
360
+ <w:tab w:val="right" w:leader="dot" w:pos="9072"/>
361
+ </w:tabs>
362
+ <w:spacing w:before="120" w:after="40" w:line="260" w:lineRule="auto"/>
363
+ <w:ind w:left="0" w:right="0"/>
340
364
  <w:jc w:val="left"/>
341
365
  </w:pPr>
342
366
  <w:rPr>
@@ -354,10 +378,18 @@ STYLES = {
354
378
  <w:next w:val="Normal"/>
355
379
  <w:qFormat/>
356
380
  <w:pPr>
357
- <w:spacing w:before="0" w:after="0" w:line="240" w:lineRule="auto"/>
381
+ <w:tabs>
382
+ <w:tab w:val="right" w:leader="dot" w:pos="9072"/>
383
+ </w:tabs>
384
+ <w:spacing w:before="20" w:after="20" w:line="240" w:lineRule="auto"/>
385
+ <w:ind w:left="360" w:right="0"/>
358
386
  <w:jc w:val="left"/>
359
387
  </w:pPr>
360
388
  <w:rPr>
389
+ {TNR}
390
+ <w:color w:val="000000"/>
391
+ <w:sz w:val="24"/>
392
+ <w:szCs w:val="24"/>
361
393
  <w:b w:val="0"/>
362
394
  <w:bCs w:val="0"/>
363
395
  </w:rPr>
@@ -368,10 +400,18 @@ STYLES = {
368
400
  <w:next w:val="Normal"/>
369
401
  <w:qFormat/>
370
402
  <w:pPr>
403
+ <w:tabs>
404
+ <w:tab w:val="right" w:leader="dot" w:pos="9072"/>
405
+ </w:tabs>
371
406
  <w:spacing w:before="0" w:after="0" w:line="240" w:lineRule="auto"/>
407
+ <w:ind w:left="720" w:right="0"/>
372
408
  <w:jc w:val="left"/>
373
409
  </w:pPr>
374
410
  <w:rPr>
411
+ {TNR}
412
+ <w:color w:val="000000"/>
413
+ <w:sz w:val="24"/>
414
+ <w:szCs w:val="24"/>
375
415
  <w:b w:val="0"/>
376
416
  <w:bCs w:val="0"/>
377
417
  </w:rPr>