Files
reTraining/build_publication_docs.py
T

1094 lines
46 KiB
Python

#!/usr/bin/env python3
"""
Publication-Grade Document Compiler for LibreOffice Flat XML (.fodt) and PDF.
Milestone 3 - Retraining & Live Counting Documentation
"""
import os
import sys
import re
import base64
import xml.etree.ElementTree as ET
from PIL import Image
WORKSPACE_ROOT = "/home/asus/feedmill/reTraining"
MD_PATH = os.path.join(WORKSPACE_ROOT, "docs/PANDUAN_SISTEM_LENGKAP.md")
FODT_PATH = os.path.join(WORKSPACE_ROOT, "docs/PANDUAN_SISTEM_LENGKAP.fodt")
PDF_PATH = os.path.join(WORKSPACE_ROOT, "docs/PANDUAN_SISTEM_LENGKAP.pdf")
def escape_xml(s):
if s is None:
return ""
return (s.replace("&", "&")
.replace("<", "&lt;")
.replace(">", "&gt;")
.replace('"', "&quot;")
.replace("'", "&apos;"))
def escape_html(s):
if s is None:
return ""
return (s.replace("&", "&amp;")
.replace("<", "&lt;")
.replace(">", "&gt;")
.replace('"', "&quot;"))
def get_image_info(rel_path):
# Determine full path
if rel_path.startswith("docs/"):
full_path = os.path.join(WORKSPACE_ROOT, rel_path)
elif os.path.exists(os.path.join(WORKSPACE_ROOT, "docs", rel_path)):
full_path = os.path.join(WORKSPACE_ROOT, "docs", rel_path)
elif os.path.exists(os.path.join(WORKSPACE_ROOT, rel_path)):
full_path = os.path.join(WORKSPACE_ROOT, rel_path)
else:
raise FileNotFoundError(f"Image not found: {rel_path}")
with open(full_path, "rb") as f:
data = f.read()
b64_str = base64.b64encode(data).decode("ascii")
with Image.open(full_path) as img:
w_px, h_px = img.size
# Printable width in A4 is 17.0 cm (21.0 - 2.0 - 2.0)
aspect = w_px / h_px
if aspect > 1.7:
# Diagram (16:9)
width_cm = 16.5
height_cm = width_cm / aspect
else:
# 16:10 screenshots
width_cm = 15.8
height_cm = width_cm / aspect
return {
"full_path": full_path,
"base64": b64_str,
"width_px": w_px,
"height_px": h_px,
"width_cm": round(width_cm, 2),
"height_cm": round(height_cm, 2),
"data_uri": f"data:image/png;base64,{b64_str}"
}
def parse_inlines_to_odf(text):
# Tokenize inline formatting: code, bolditalic, bold, italic, links, math
pattern = re.compile(r"(`[^`]+`|\*\*\*[^*]+\*\*\*|\*\*[^*]+\*\*|\*[^*]+\*|\[[^\]]+\]\([^)]+\)|\$[^$]+\$)")
pos = 0
result = []
for m in pattern.finditer(text):
if m.start() > pos:
result.append(escape_xml(text[pos:m.start()]))
token = m.group(0)
if token.startswith("`") and token.endswith("`"):
code_text = escape_xml(token[1:-1])
result.append(f'<text:span text:style-name="T_Code">{code_text}</text:span>')
elif token.startswith("***") and token.endswith("***"):
t = escape_xml(token[3:-3])
result.append(f'<text:span text:style-name="T_BoldItalic">{t}</text:span>')
elif token.startswith("**") and token.endswith("**"):
t = escape_xml(token[2:-2])
result.append(f'<text:span text:style-name="T_Bold">{t}</text:span>')
elif token.startswith("*") and token.endswith("*"):
t = escape_xml(token[1:-1])
result.append(f'<text:span text:style-name="T_Italic">{t}</text:span>')
elif token.startswith("[") and "]" in token and "(" in token:
lm = re.match(r"\[([^\]]+)\]\(([^)]+)\)", token)
if lm:
lt, lu = lm.groups()
result.append(f'<text:a xlink:type="simple" xlink:href="{escape_xml(lu)}"><text:span text:style-name="T_Link">{escape_xml(lt)}</text:span></text:a>')
else:
result.append(escape_xml(token))
elif token.startswith("$") and token.endswith("$"):
t = escape_xml(token[1:-1])
result.append(f'<text:span text:style-name="T_Italic">{t}</text:span>')
else:
result.append(escape_xml(token))
pos = m.end()
if pos < len(text):
result.append(escape_xml(text[pos:]))
return "".join(result)
def parse_inlines_to_html(text):
text = escape_html(text)
# Inline code
text = re.sub(r"`([^`]+)`", r'<code class="inline-code">\1</code>', text)
# Bold italic
text = re.sub(r"\*\*\*([^*]+)\*\*\*", r"<strong><em>\1</em></strong>", text)
# Bold
text = re.sub(r"\*\*([^*]+)\*\*", r"<strong>\1</strong>", text)
# Italic
text = re.sub(r"\*([^*]+)\*", r"<em>\1</em>", text)
# Links
text = re.sub(r"\[([^\]]+)\]\(([^)]+)\)", r'<a href="\2">\1</a>', text)
# Math var
text = re.sub(r"\$([^$]+)\$", r'<span class="math-var">\1</span>', text)
return text
def parse_markdown_blocks(content):
lines = content.split("\n")
blocks = []
i = 0
n = len(lines)
while i < n:
line = lines[i]
stripped = line.strip()
# Empty line
if not stripped:
i += 1
continue
# Horizontal Rule
if stripped in ["---", "***", "___"]:
blocks.append({"type": "hr"})
i += 1
continue
# Code block
if stripped.startswith("```"):
lang = stripped[3:].strip()
code_lines = []
i += 1
while i < n and not lines[i].strip().startswith("```"):
code_lines.append(lines[i])
i += 1
if i < n: # consume closing ```
i += 1
blocks.append({"type": "code", "lang": lang, "content": "\n".join(code_lines)})
continue
# Headings
if stripped.startswith("#"):
level = 0
while level < len(stripped) and stripped[level] == "#":
level += 1
title = stripped[level:].strip()
blocks.append({"type": "heading", "level": level, "text": title})
i += 1
continue
# Table
if stripped.startswith("|") and stripped.endswith("|"):
table_lines = []
while i < n and lines[i].strip().startswith("|") and lines[i].strip().endswith("|"):
table_lines.append(lines[i].strip())
i += 1
if len(table_lines) >= 2:
header = [c.strip() for c in table_lines[0].split("|")[1:-1]]
rows = []
for r in table_lines[2:]:
cols = [c.strip() for c in r.split("|")[1:-1]]
rows.append(cols)
blocks.append({"type": "table", "header": header, "rows": rows})
continue
# Image
img_match = re.match(r"^!\[(.*?)\]\((.*?)\)$", stripped)
if img_match:
alt, src = img_match.groups()
blocks.append({"type": "image", "alt": alt, "src": src})
i += 1
continue
# Numbered list: 1.
num_match = re.match(r"^(\d+)\.\s+(.*)$", stripped)
if num_match:
list_items = []
while i < n:
curr_stripped = lines[i].strip()
nm = re.match(r"^(\d+)\.\s+(.*)$", curr_stripped)
if nm:
list_items.append(nm.group(2))
i += 1
else:
break
blocks.append({"type": "ordered_list", "items": list_items})
continue
# Bullet list: - or *
bullet_match = re.match(r"^[-*]\s+(.*)$", stripped)
if bullet_match and not re.match(r"^\*[^*]+\*$", stripped):
bullet_items = []
while i < n:
curr_stripped = lines[i].strip()
bm = re.match(r"^[-*]\s+(.*)$", curr_stripped)
if bm and not re.match(r"^\*[^*]+\*$", curr_stripped):
bullet_items.append(bm.group(1))
i += 1
else:
break
blocks.append({"type": "unordered_list", "items": bullet_items})
continue
# Callout / Admonition (*Perhatian:...* or *Invarian penting:...*)
if stripped.startswith("*Perhatian:") or stripped.startswith("*Invarian penting:") or stripped.startswith("*Catatan:") or stripped.startswith("*Peringatan:"):
blocks.append({"type": "admonition", "text": stripped})
i += 1
continue
# Caption or italic paragraph under image (*Gambar X:...* or *Unduh format...*)
if stripped.startswith("*Gambar ") or stripped.startswith("*Unduh format vektor"):
blocks.append({"type": "caption", "text": stripped})
i += 1
continue
# Standard paragraph
p_lines = [stripped]
i += 1
while i < n:
next_line = lines[i]
next_stripped = next_line.strip()
if (not next_stripped or
next_stripped.startswith("#") or
next_stripped.startswith("```") or
next_stripped.startswith("|") or
next_stripped.startswith("---") or
next_stripped.startswith("![") or
re.match(r"^\d+\.\s+", next_stripped) or
re.match(r"^[-*]\s+", next_stripped) or
next_stripped.startswith("*Perhatian:") or
next_stripped.startswith("*Invarian penting:") or
next_stripped.startswith("*Gambar ") or
next_stripped.startswith("*Unduh format")):
break
p_lines.append(next_stripped)
i += 1
blocks.append({"type": "paragraph", "text": " ".join(p_lines)})
return blocks
print("Parser module loaded successfully.")
def generate_fodt(blocks):
print("Generating OASIS OpenDocument Text Flat XML (.fodt)...")
# Header
out = []
out.append('<?xml version="1.0" encoding="UTF-8"?>')
out.append('<office:document xmlns:office="urn:oasis:names:tc:opendocument:xmlns:office:1.0"')
out.append(' xmlns:style="urn:oasis:names:tc:opendocument:xmlns:style:1.0"')
out.append(' xmlns:text="urn:oasis:names:tc:opendocument:xmlns:text:1.0"')
out.append(' xmlns:table="urn:oasis:names:tc:opendocument:xmlns:table:1.0"')
out.append(' xmlns:draw="urn:oasis:names:tc:opendocument:xmlns:drawing:1.0"')
out.append(' xmlns:fo="urn:oasis:names:tc:opendocument:xmlns:xsl-fo-compatible:1.0"')
out.append(' xmlns:xlink="http://www.w3.org/1999/xlink"')
out.append(' xmlns:dc="http://purl.org/dc/elements/1.1/"')
out.append(' xmlns:meta="urn:oasis:names:tc:opendocument:xmlns:meta:1.0"')
out.append(' xmlns:number="urn:oasis:names:tc:opendocument:xmlns:datastyle:1.0"')
out.append(' xmlns:svg="urn:oasis:names:tc:opendocument:xmlns:svg-compatible:1.0"')
out.append(' office:version="1.3"')
out.append(' office:mimetype="application/vnd.oasis.opendocument.text">')
# Meta
out.append(' <office:meta>')
out.append(' <dc:title>PANDUAN SISTEM LENGKAP: RETRAINING, ANOTASI &amp; LIVE COUNTING KARUNG KONVEYOR</dc:title>')
out.append(' <dc:description>Dokumentasi Arsitektur, Prosedur Operasional Standar, dan Panduan Referensi Teknis Produksi v4.2.0</dc:description>')
out.append(' <dc:language>id-ID</dc:language>')
out.append(' <dc:creator>Senior Computer Vision &amp; Platform Engineer</dc:creator>')
out.append(' <meta:creation-date>2026-08-27T00:00:00Z</meta:creation-date>')
out.append(' </office:meta>')
# Font face declarations
out.append(' <office:font-face-decls>')
out.append(' <style:font-face style:name="Liberation Sans" svg:font-family="Liberation Sans, Inter, Arial, sans-serif"/>')
out.append(' <style:font-face style:name="Liberation Mono" svg:font-family="Liberation Mono, JetBrains Mono, Courier New, monospace"/>')
out.append(' </office:font-face-decls>')
# Styles
out.append(' <office:styles>')
# Default graphic
out.append(' <style:default-style style:family="graphic">')
out.append(' <style:graphic-properties svg:stroke-color="#cbd5e1" svg:stroke-width="0.5pt" draw:fill="none"/>')
out.append(' </style:default-style>')
# Default paragraph
out.append(' <style:default-style style:family="paragraph">')
out.append(' <style:paragraph-properties fo:margin-top="0cm" fo:margin-bottom="0.25cm" fo:line-height="120%" fo:text-align="justify"/>')
out.append(' <style:text-properties style:font-name="Liberation Sans" fo:font-size="9.5pt" fo:color="#1e293b"/>')
out.append(' </style:default-style>')
# Default table
out.append(' <style:default-style style:family="table">')
out.append(' <style:table-properties table:border-model="collapsing"/>')
out.append(' </style:default-style>')
# Default table cell
out.append(' <style:default-style style:family="table-cell">')
out.append(' <style:table-cell-properties fo:padding="0.15cm" fo:border="0.5pt solid #cbd5e1"/>')
out.append(' </style:default-style>')
# Named Styles
out.append(' <style:style style:name="Title" style:family="paragraph">')
out.append(' <style:paragraph-properties fo:margin-top="0.4cm" fo:margin-bottom="0.25cm" fo:text-align="center"/>')
out.append(' <style:text-properties style:font-name="Liberation Sans" fo:font-size="18pt" fo:font-weight="bold" fo:color="#0f172a"/>')
out.append(' </style:style>')
out.append(' <style:style style:name="Subtitle" style:family="paragraph">')
out.append(' <style:paragraph-properties fo:margin-top="0cm" fo:margin-bottom="0.6cm" fo:text-align="center"/>')
out.append(' <style:text-properties style:font-name="Liberation Sans" fo:font-size="11pt" fo:font-style="italic" fo:color="#475569"/>')
out.append(' </style:style>')
out.append(' <style:style style:name="Heading_20_1" style:display-name="Heading 1" style:family="paragraph">')
out.append(' <style:paragraph-properties fo:margin-top="0.8cm" fo:margin-bottom="0.3cm" fo:break-before="page" fo:keep-with-next="always"/>')
out.append(' <style:text-properties style:font-name="Liberation Sans" fo:font-size="15pt" fo:font-weight="bold" fo:color="#0284c7"/>')
out.append(' </style:style>')
out.append(' <style:style style:name="Heading_20_2" style:display-name="Heading 2" style:family="paragraph">')
out.append(' <style:paragraph-properties fo:margin-top="0.5cm" fo:margin-bottom="0.2cm" fo:keep-with-next="always"/>')
out.append(' <style:text-properties style:font-name="Liberation Sans" fo:font-size="12pt" fo:font-weight="bold" fo:color="#0f172a"/>')
out.append(' </style:style>')
out.append(' <style:style style:name="Heading_20_3" style:display-name="Heading 3" style:family="paragraph">')
out.append(' <style:paragraph-properties fo:margin-top="0.4cm" fo:margin-bottom="0.15cm" fo:keep-with-next="always"/>')
out.append(' <style:text-properties style:font-name="Liberation Sans" fo:font-size="10.5pt" fo:font-weight="bold" fo:color="#334155"/>')
out.append(' </style:style>')
out.append(' <style:style style:name="Heading_20_4" style:display-name="Heading 4" style:family="paragraph">')
out.append(' <style:paragraph-properties fo:margin-top="0.3cm" fo:margin-bottom="0.1cm" fo:keep-with-next="always"/>')
out.append(' <style:text-properties style:font-name="Liberation Sans" fo:font-size="9.5pt" fo:font-weight="bold" fo:color="#475569"/>')
out.append(' </style:style>')
out.append(' <style:style style:name="Standard" style:family="paragraph"/>')
out.append(' <style:style style:name="Body_20_Text" style:display-name="Body Text" style:family="paragraph">')
out.append(' <style:paragraph-properties fo:margin-top="0cm" fo:margin-bottom="0.25cm" fo:line-height="120%" fo:text-align="justify"/>')
out.append(' <style:text-properties style:font-name="Liberation Sans" fo:font-size="9.5pt" fo:color="#1e293b"/>')
out.append(' </style:style>')
out.append(' <style:style style:name="CodeBlock" style:family="paragraph">')
out.append(' <style:paragraph-properties fo:margin-top="0.2cm" fo:margin-bottom="0.3cm" fo:background-color="#0f172a" fo:padding="0.25cm" fo:line-height="115%"/>')
out.append(' <style:text-properties style:font-name="Liberation Mono" fo:font-size="8pt" fo:color="#f8fafc"/>')
out.append(' </style:style>')
out.append(' <style:style style:name="Admonition" style:family="paragraph">')
out.append(' <style:paragraph-properties fo:margin-top="0.25cm" fo:margin-bottom="0.35cm" fo:background-color="#f0f9ff" fo:border-left="3pt solid #0284c7" fo:border-top="none" fo:border-right="none" fo:border-bottom="none" fo:padding-left="0.3cm" fo:padding-top="0.2cm" fo:padding-bottom="0.2cm" fo:padding-right="0.2cm"/>')
out.append(' <style:text-properties style:font-name="Liberation Sans" fo:font-size="9pt" fo:color="#0369a1"/>')
out.append(' </style:style>')
out.append(' <style:style style:name="AdmonitionWarning" style:family="paragraph">')
out.append(' <style:paragraph-properties fo:margin-top="0.25cm" fo:margin-bottom="0.35cm" fo:background-color="#fef2f2" fo:border-left="3pt solid #ef4444" fo:border-top="none" fo:border-right="none" fo:border-bottom="none" fo:padding-left="0.3cm" fo:padding-top="0.2cm" fo:padding-bottom="0.2cm" fo:padding-right="0.2cm"/>')
out.append(' <style:text-properties style:font-name="Liberation Sans" fo:font-size="9pt" fo:color="#991b1b"/>')
out.append(' </style:style>')
out.append(' <style:style style:name="Caption" style:family="paragraph">')
out.append(' <style:paragraph-properties fo:margin-top="0.15cm" fo:margin-bottom="0.4cm" fo:text-align="center"/>')
out.append(' <style:text-properties style:font-name="Liberation Sans" fo:font-size="8.5pt" fo:font-style="italic" fo:color="#64748b"/>')
out.append(' </style:style>')
out.append(' <style:style style:name="Illustration" style:family="paragraph">')
out.append(' <style:paragraph-properties fo:margin-top="0.3cm" fo:margin-bottom="0.1cm" fo:text-align="center" fo:keep-with-next="always"/>')
out.append(' </style:style>')
out.append(' <style:style style:name="TableHeaderText" style:family="paragraph">')
out.append(' <style:paragraph-properties fo:margin-top="0cm" fo:margin-bottom="0cm"/>')
out.append(' <style:text-properties style:font-name="Liberation Sans" fo:font-size="8.5pt" fo:font-weight="bold" fo:color="#ffffff"/>')
out.append(' </style:style>')
out.append(' <style:style style:name="TableBodyText" style:family="paragraph">')
out.append(' <style:paragraph-properties fo:margin-top="0cm" fo:margin-bottom="0cm"/>')
out.append(' <style:text-properties style:font-name="Liberation Sans" fo:font-size="8.5pt" fo:color="#1e293b"/>')
out.append(' </style:style>')
out.append(' <style:style style:name="ListItemText" style:family="paragraph">')
out.append(' <style:paragraph-properties fo:margin-top="0cm" fo:margin-bottom="0.12cm" fo:margin-left="0.6cm" fo:text-indent="-0.4cm"/>')
out.append(' <style:text-properties style:font-name="Liberation Sans" fo:font-size="9.5pt" fo:color="#1e293b"/>')
out.append(' </style:style>')
out.append(' <style:style style:name="Header" style:family="paragraph">')
out.append(' <style:paragraph-properties fo:text-align="right" fo:border-bottom="0.5pt solid #cbd5e1" fo:padding-bottom="0.1cm"/>')
out.append(' <style:text-properties style:font-name="Liberation Sans" fo:font-size="8pt" fo:color="#64748b"/>')
out.append(' </style:style>')
out.append(' <style:style style:name="Footer" style:family="paragraph">')
out.append(' <style:paragraph-properties fo:text-align="center" fo:border-top="0.5pt solid #cbd5e1" fo:padding-top="0.1cm"/>')
out.append(' <style:text-properties style:font-name="Liberation Sans" fo:font-size="8pt" fo:color="#64748b"/>')
out.append(' </style:style>')
# List styles
out.append(' <text:list-style style:name="L_Bullet">')
out.append(' <text:list-level-style-bullet text:level="1" text:bullet-char="•">')
out.append(' <style:list-level-properties text:list-level-position-and-space-mode="label-alignment">')
out.append(' <style:list-level-label-alignment text:label-followed-by="listtab" text:list-tab-stop-position="0.6cm" fo:text-indent="-0.4cm" fo:margin-left="0.6cm"/>')
out.append(' </style:list-level-properties>')
out.append(' </text:list-level-style-bullet>')
out.append(' </text:list-style>')
out.append(' <text:list-style style:name="L_Number">')
out.append(' <text:list-level-style-number text:level="1" style:num-format="1" style:num-suffix=".">')
out.append(' <style:list-level-properties text:list-level-position-and-space-mode="label-alignment">')
out.append(' <style:list-level-label-alignment text:label-followed-by="listtab" text:list-tab-stop-position="0.6cm" fo:text-indent="-0.4cm" fo:margin-left="0.6cm"/>')
out.append(' </style:list-level-properties>')
out.append(' </text:list-level-style-number>')
out.append(' </text:list-style>')
out.append(' </office:styles>')
# Automatic styles
out.append(' <office:automatic-styles>')
# Page layout A4 (21.0 x 29.7 cm, 2.0 cm margins)
out.append(' <style:page-layout style:name="PM1">')
out.append(' <style:page-layout-properties fo:page-width="21.0cm" fo:page-height="29.7cm" style:num-format="1" style:print-orientation="portrait" fo:margin-top="2.0cm" fo:margin-bottom="2.0cm" fo:margin-left="2.0cm" fo:margin-right="2.0cm"/>')
out.append(' <style:header-style>')
out.append(' <style:header-footer-properties fo:min-height="0.6cm" fo:margin-bottom="0.4cm"/>')
out.append(' </style:header-style>')
out.append(' <style:footer-style>')
out.append(' <style:header-footer-properties fo:min-height="0.6cm" fo:margin-top="0.4cm"/>')
out.append(' </style:footer-style>')
out.append(' </style:page-layout>')
# Character styles
out.append(' <style:style style:name="T_Bold" style:family="text"><style:text-properties fo:font-weight="bold"/></style:style>')
out.append(' <style:style style:name="T_Italic" style:family="text"><style:text-properties fo:font-style="italic"/></style:style>')
out.append(' <style:style style:name="T_BoldItalic" style:family="text"><style:text-properties fo:font-weight="bold" fo:font-style="italic"/></style:style>')
out.append(' <style:style style:name="T_Code" style:family="text"><style:text-properties style:font-name="Liberation Mono" fo:font-size="8.5pt" fo:background-color="#f1f5f9" fo:color="#0f172a"/></style:style>')
out.append(' <style:style style:name="T_Link" style:family="text"><style:text-properties fo:color="#0284c7" fo:text-underline-style="solid"/></style:style>')
# Graphic Frame style
out.append(' <style:style style:name="FrameImage" style:family="graphic">')
out.append(' <style:graphic-properties style:horizontal-pos="center" style:horizontal-rel="paragraph" svg:stroke-color="#cbd5e1" svg:stroke-width="0.5pt" draw:fill="none" fo:margin-top="0.1cm" fo:margin-bottom="0.1cm"/>')
out.append(' </style:style>')
# Table styles
out.append(' <style:style style:name="TableAuto" style:family="table">')
out.append(' <style:table-properties style:width="17.0cm" table:align="center" fo:margin-top="0.2cm" fo:margin-bottom="0.35cm"/>')
out.append(' </style:style>')
out.append(' <style:style style:name="TableCellHeader" style:family="table-cell">')
out.append(' <style:table-cell-properties fo:background-color="#0f172a" fo:padding="0.15cm" fo:border="0.5pt solid #cbd5e1"/>')
out.append(' </style:style>')
out.append(' <style:style style:name="TableCellData" style:family="table-cell">')
out.append(' <style:table-cell-properties fo:background-color="#ffffff" fo:padding="0.15cm" fo:border="0.5pt solid #cbd5e1"/>')
out.append(' </style:style>')
out.append(' <style:style style:name="TableCellDataAlt" style:family="table-cell">')
out.append(' <style:table-cell-properties fo:background-color="#f8fafc" fo:padding="0.15cm" fo:border="0.5pt solid #cbd5e1"/>')
out.append(' </style:style>')
out.append(' </office:automatic-styles>')
# Master styles
out.append(' <office:master-styles>')
out.append(' <style:master-page style:name="Standard" style:page-layout-name="PM1">')
out.append(' <style:header>')
out.append(' <text:p text:style-name="Header">PANDUAN SISTEM LENGKAP — RETRAINING, ANOTASI &amp; LIVE COUNTING</text:p>')
out.append(' </style:header>')
out.append(' <style:footer>')
out.append(' <text:p text:style-name="Footer">Dokumentasi Teknis Produksi v4.2.0 | Halaman <text:page-number text:select-page="current">1</text:page-number> dari <text:page-count>1</text:page-count></text:p>')
out.append(' </style:footer>')
out.append(' </style:master-page>')
out.append(' </office:master-styles>')
# Document Body
out.append(' <office:body>')
out.append(' <office:text>')
img_idx = 0
tbl_idx = 0
first_h1 = True
for block in blocks:
btype = block["type"]
if btype == "hr":
# Horizontal separator
out.append(' <text:p text:style-name="Body_20_Text"/>')
elif btype == "heading":
level = block["level"]
text = block["text"]
if level == 1:
# Check if it is Document Title or Chapter
if first_h1 and "PANDUAN SISTEM LENGKAP" in text:
out.append(f' <text:p text:style-name="Title">{parse_inlines_to_odf(text)}</text:p>')
first_h1 = False
else:
out.append(f' <text:h text:outline-level="1" text:style-name="Heading_20_1">{parse_inlines_to_odf(text)}</text:h>')
elif level == 2:
out.append(f' <text:h text:outline-level="2" text:style-name="Heading_20_2">{parse_inlines_to_odf(text)}</text:h>')
elif level == 3:
out.append(f' <text:h text:outline-level="3" text:style-name="Heading_20_3">{parse_inlines_to_odf(text)}</text:h>')
else:
out.append(f' <text:h text:outline-level="4" text:style-name="Heading_20_4">{parse_inlines_to_odf(text)}</text:h>')
elif btype == "paragraph":
text = block["text"]
if text.startswith("**Dokumentasi Arsitektur"):
out.append(f' <text:p text:style-name="Subtitle">{parse_inlines_to_odf(text)}</text:p>')
else:
out.append(f' <text:p text:style-name="Body_20_Text">{parse_inlines_to_odf(text)}</text:p>')
elif btype == "admonition":
text = block["text"]
style = "AdmonitionWarning" if "Perhatian" in text or "Peringatan" in text else "Admonition"
out.append(f' <text:p text:style-name="{style}">{parse_inlines_to_odf(text)}</text:p>')
elif btype == "code":
code_text = block["content"]
lines = code_text.split("\n")
while lines and not lines[-1].strip():
lines.pop()
xml_lines = []
for cline in lines:
escaped = escape_xml(cline)
def replace_spaces(match):
count = len(match.group(0))
return f'<text:s text:c="{count}"/>'
processed_line = re.sub(r" {2,}", replace_spaces, escaped)
xml_lines.append(processed_line)
inner = "<text:line-break/>".join(xml_lines)
out.append(f' <text:p text:style-name="CodeBlock">{inner}</text:p>')
elif btype == "image":
img_idx += 1
src = block["src"]
alt = block["alt"]
img_info = get_image_info(src)
w_cm = img_info["width_cm"]
h_cm = img_info["height_cm"]
b64 = img_info["base64"]
out.append(' <text:p text:style-name="Illustration">')
out.append(f' <draw:frame draw:style-name="FrameImage" draw:name="Img_{img_idx}" text:anchor-type="paragraph" svg:width="{w_cm}cm" svg:height="{h_cm}cm" draw:z-index="0">')
out.append(' <draw:image>')
out.append(f' <office:binary-data>{b64}</office:binary-data>')
out.append(' </draw:image>')
out.append(' </draw:frame>')
out.append(' </text:p>')
elif btype == "caption":
text = block["text"]
out.append(f' <text:p text:style-name="Caption">{parse_inlines_to_odf(text)}</text:p>')
elif btype == "table":
tbl_idx += 1
header = block["header"]
rows = block["rows"]
cols_count = len(header)
out.append(f' <table:table table:name="Table_{tbl_idx}" table:style-name="TableAuto">')
out.append(f' <table:table-column table:number-columns-repeated="{cols_count}"/>')
out.append(' <table:table-header-rows>')
out.append(' <table:table-row>')
for cell in header:
out.append(' <table:table-cell table:style-name="TableCellHeader" office:value-type="string">')
out.append(f' <text:p text:style-name="TableHeaderText">{parse_inlines_to_odf(cell)}</text:p>')
out.append(' </table:table-cell>')
out.append(' </table:table-row>')
out.append(' </table:table-header-rows>')
for r_idx, row in enumerate(rows):
cell_style = "TableCellDataAlt" if r_idx % 2 == 1 else "TableCellData"
out.append(' <table:table-row>')
for cell in row:
out.append(f' <table:table-cell table:style-name="{cell_style}" office:value-type="string">')
out.append(f' <text:p text:style-name="TableBodyText">{parse_inlines_to_odf(cell)}</text:p>')
out.append(' </table:table-cell>')
out.append(' </table:table-row>')
out.append(' </table:table>')
elif btype == "ordered_list":
items = block["items"]
out.append(' <text:list text:style-name="L_Number">')
for item in items:
out.append(' <text:list-item>')
out.append(f' <text:p text:style-name="ListItemText">{parse_inlines_to_odf(item)}</text:p>')
out.append(' </text:list-item>')
out.append(' </text:list>')
elif btype == "unordered_list":
items = block["items"]
out.append(' <text:list text:style-name="L_Bullet">')
for item in items:
out.append(' <text:list-item>')
out.append(f' <text:p text:style-name="ListItemText">{parse_inlines_to_odf(item)}</text:p>')
out.append(' </text:list-item>')
out.append(' </text:list>')
out.append(' </office:text>')
out.append(' </office:body>')
out.append('</office:document>')
fodt_content = "\n".join(out)
with open(FODT_PATH, "w", encoding="utf-8") as f:
f.write(fodt_content)
print(f"FODT successfully written to {FODT_PATH} ({os.path.getsize(FODT_PATH)} bytes)")
def generate_html_and_pdf(blocks):
print("Generating Publication-Grade HTML and compiling to PDF via Playwright...")
from playwright.sync_api import sync_playwright
html = []
html.append('<!DOCTYPE html>')
html.append('<html lang="id">')
html.append('<head>')
html.append(' <meta charset="UTF-8">')
html.append(' <title>PANDUAN SISTEM LENGKAP: RETRAINING, ANOTASI &amp; LIVE COUNTING KARUNG KONVEYOR</title>')
html.append(' <style>')
html.append("""
@page {
size: A4 portrait;
margin: 20mm 18mm 22mm 18mm;
}
* {
box-sizing: border-box;
}
body {
font-family: Inter, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Liberation Sans", sans-serif;
font-size: 9.5pt;
line-height: 1.45;
color: #1e293b;
background: #ffffff;
margin: 0;
padding: 0;
}
.cover-container {
padding-top: 10px;
margin-bottom: 20px;
}
.cover-top-bar {
height: 5px;
background: linear-gradient(90deg, #0284c7 0%, #0369a1 50%, #0f172a 100%);
border-radius: 3px;
margin-bottom: 16px;
}
.doc-title {
font-size: 18pt;
font-weight: 800;
color: #0f172a;
line-height: 1.25;
margin: 0 0 6px 0;
letter-spacing: -0.3px;
text-align: left;
}
.doc-subtitle {
font-size: 10.5pt;
font-weight: 500;
color: #475569;
margin: 0 0 16px 0;
line-height: 1.35;
}
.meta-grid {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 8px;
background: #f8fafc;
border: 1px solid #e2e8f0;
border-radius: 6px;
padding: 10px 14px;
margin-bottom: 16px;
}
.meta-item {
font-size: 8.5pt;
color: #334155;
}
.meta-label {
font-weight: 700;
color: #0f172a;
}
.toc-card {
background: #ffffff;
border: 1px solid #e2e8f0;
border-radius: 6px;
padding: 12px 16px;
margin-bottom: 20px;
}
.toc-title {
font-size: 11pt;
font-weight: 700;
color: #0f172a;
margin: 0 0 8px 0;
border-bottom: 1px solid #e2e8f0;
padding-bottom: 4px;
}
.toc-list {
margin: 0;
padding-left: 18px;
font-size: 8.5pt;
color: #0284c7;
line-height: 1.4;
}
.toc-list li {
margin-bottom: 2px;
}
/* Chapter and Headings */
.chapter-title {
page-break-before: always;
break-before: page;
font-size: 15pt;
font-weight: 800;
color: #0284c7;
border-bottom: 2px solid #0284c7;
padding-bottom: 4px;
margin-top: 0;
margin-bottom: 12px;
letter-spacing: -0.2px;
}
h2.section-title {
font-size: 12pt;
font-weight: 700;
color: #0f172a;
margin-top: 16px;
margin-bottom: 8px;
break-after: avoid;
page-break-after: avoid;
border-left: 3px solid #0284c7;
padding-left: 8px;
}
h3.subsection-title {
font-size: 10.5pt;
font-weight: 700;
color: #334155;
margin-top: 12px;
margin-bottom: 6px;
break-after: avoid;
page-break-after: avoid;
}
h4.sub-subsection-title {
font-size: 9.5pt;
font-weight: 700;
color: #475569;
margin-top: 10px;
margin-bottom: 4px;
break-after: avoid;
page-break-after: avoid;
}
p {
margin-top: 0;
margin-bottom: 8px;
text-align: justify;
}
/* Code Block */
pre.code-block {
background-color: #0f172a;
color: #f8fafc;
font-family: 'Liberation Mono', 'JetBrains Mono', 'Fira Code', Consolas, monospace;
font-size: 8pt;
line-height: 1.35;
padding: 10px 12px;
border-radius: 6px;
border: 1px solid #1e293b;
margin: 10px 0 12px 0;
white-space: pre-wrap;
word-break: break-all;
page-break-inside: avoid;
break-inside: avoid;
}
code.inline-code {
background-color: #f1f5f9;
color: #0f172a;
font-family: 'Liberation Mono', 'JetBrains Mono', Consolas, monospace;
font-size: 8.5pt;
padding: 1px 4px;
border-radius: 3px;
border: 1px solid #e2e8f0;
}
.math-var {
font-style: italic;
font-family: "Times New Roman", serif;
font-weight: 600;
color: #0f172a;
}
/* Admonitions */
.admonition-info {
background-color: #f0f9ff;
border-left: 4px solid #0284c7;
color: #0369a1;
padding: 8px 12px;
border-radius: 0 6px 6px 0;
margin: 10px 0;
font-size: 9pt;
page-break-inside: avoid;
break-inside: avoid;
}
.admonition-warning {
background-color: #fef2f2;
border-left: 4px solid #ef4444;
color: #991b1b;
padding: 8px 12px;
border-radius: 0 6px 6px 0;
margin: 10px 0;
font-size: 9pt;
page-break-inside: avoid;
break-inside: avoid;
}
.admonition-tip {
background-color: #f8fafc;
border-left: 4px solid #0ea5e9;
color: #0f172a;
padding: 8px 12px;
border-radius: 0 6px 6px 0;
margin: 10px 0;
font-size: 9pt;
page-break-inside: avoid;
break-inside: avoid;
}
/* Tables */
table.doc-table {
width: 100%;
border-collapse: collapse;
margin: 10px 0 14px 0;
font-size: 8.5pt;
page-break-inside: auto;
}
table.doc-table thead {
background-color: #0f172a;
color: #ffffff;
display: table-header-group;
}
table.doc-table th {
padding: 6px 8px;
text-align: left;
font-weight: 600;
border: 1px solid #cbd5e1;
}
table.doc-table td {
padding: 5px 8px;
border: 1px solid #cbd5e1;
line-height: 1.35;
}
table.doc-table tbody tr:nth-child(even) {
background-color: #f8fafc;
}
table.doc-table tbody tr:nth-child(odd) {
background-color: #ffffff;
}
table.doc-table tr {
page-break-inside: avoid;
break-inside: avoid;
}
/* Figures */
.figure-container {
text-align: center;
margin: 12px 0 8px 0;
page-break-inside: avoid;
break-inside: avoid;
}
.figure-img {
max-width: 98%;
height: auto;
border: 1px solid #cbd5e1;
border-radius: 6px;
box-shadow: 0 2px 5px rgba(0, 0, 0, 0.05);
display: block;
margin: 0 auto;
}
.figure-caption {
font-size: 8.5pt;
font-style: italic;
color: #64748b;
text-align: center;
margin-top: 5px;
margin-bottom: 12px;
line-height: 1.35;
}
/* Lists */
ul, ol {
margin-top: 0;
margin-bottom: 8px;
padding-left: 20px;
}
li {
margin-bottom: 3px;
line-height: 1.4;
}
a {
color: #0284c7;
text-decoration: none;
}
a:hover {
text-decoration: underline;
}
""")
html.append(' </style>')
html.append('</head>')
html.append('<body>')
first_h1 = True
in_meta_section = False
for block in blocks:
btype = block["type"]
if btype == "hr":
if in_meta_section:
in_meta_section = False
elif btype == "heading":
level = block["level"]
text = block["text"]
if level == 1:
if first_h1 and "PANDUAN SISTEM LENGKAP" in text:
html.append('<div class="cover-container">')
html.append(' <div class="cover-top-bar"></div>')
html.append(f' <h1 class="doc-title">{parse_inlines_to_html(text)}</h1>')
first_h1 = False
else:
html.append(f'<h1 class="chapter-title">{parse_inlines_to_html(text)}</h1>')
elif level == 2:
if text == "Daftar Isi":
html.append('<div class="toc-card">')
html.append(' <div class="toc-title">Daftar Isi</div>')
else:
html.append(f'<h2 class="section-title">{parse_inlines_to_html(text)}</h2>')
elif level == 3:
if text == "Informasi Dokumen":
in_meta_section = True
html.append('<div class="meta-grid">')
else:
html.append(f'<h3 class="subsection-title">{parse_inlines_to_html(text)}</h3>')
else:
html.append(f'<h4 class="sub-subsection-title">{parse_inlines_to_html(text)}</h4>')
elif btype == "paragraph":
text = block["text"]
if text.startswith("**Dokumentasi Arsitektur"):
html.append(f'<p class="doc-subtitle">{parse_inlines_to_html(text)}</p>')
else:
html.append(f'<p>{parse_inlines_to_html(text)}</p>')
elif btype == "admonition":
text = block["text"]
cls = "admonition-warning" if ("Perhatian" in text or "Peringatan" in text) else "admonition-info"
html.append(f'<div class="{cls}">{parse_inlines_to_html(text)}</div>')
elif btype == "code":
code_text = block["content"]
html.append(f'<pre class="code-block"><code>{escape_html(code_text)}</code></pre>')
elif btype == "image":
src = block["src"]
alt = block["alt"]
img_info = get_image_info(src)
data_uri = img_info["data_uri"]
html.append('<div class="figure-container">')
html.append(f' <img class="figure-img" src="{data_uri}" alt="{escape_html(alt)}">')
html.append('</div>')
elif btype == "caption":
text = block["text"]
html.append(f'<div class="figure-caption">{parse_inlines_to_html(text)}</div>')
elif btype == "table":
header = block["header"]
rows = block["rows"]
html.append('<table class="doc-table">')
html.append(' <thead><tr>')
for cell in header:
html.append(f' <th>{parse_inlines_to_html(cell)}</th>')
html.append(' </tr></thead>')
html.append(' <tbody>')
for row in rows:
html.append(' <tr>')
for cell in row:
html.append(f' <td>{parse_inlines_to_html(cell)}</td>')
html.append(' </tr>')
html.append(' </tbody>')
html.append('</table>')
elif btype == "ordered_list":
items = block["items"]
# Check if this is the Table of Contents list
# If so, close toc-card
html.append('<ol class="toc-list" style="margin-bottom: 0;">' if "Bab 1:" in items[0] else '<ol>')
for item in items:
html.append(f' <li>{parse_inlines_to_html(item)}</li>')
html.append('</ol>')
if "Bab 1:" in items[0]:
html.append('</div>') # close toc-card
html.append('</div>') # close cover-container
elif btype == "unordered_list":
items = block["items"]
if in_meta_section:
# Format as meta cards
for item in items:
parts = item.split(":", 1)
if len(parts) == 2:
lbl, val = parts
html.append(f' <div class="meta-item"><span class="meta-label">{parse_inlines_to_html(lbl.strip("* "))}:</span> {parse_inlines_to_html(val.strip())}</div>')
else:
html.append(f' <div class="meta-item">{parse_inlines_to_html(item)}</div>')
html.append('</div>') # close meta-grid
in_meta_section = False
else:
html.append('<ul>')
for item in items:
html.append(f' <li>{parse_inlines_to_html(item)}</li>')
html.append('</ul>')
html.append('</body>')
html.append('</html>')
html_content = "\n".join(html)
# Save debug html
debug_html_path = os.path.join(WORKSPACE_ROOT, "docs/panduan_debug.html")
with open(debug_html_path, "w", encoding="utf-8") as f:
f.write(html_content)
print(f"Debug HTML written to {debug_html_path}")
# Compile with Playwright
header_tpl = (
'<div style="font-size: 7.5pt; color: #64748b; width: 100%; text-align: right; '
'padding: 0 18mm; font-family: Inter, \'Liberation Sans\', sans-serif; '
'border-bottom: 1px solid #e2e8f0; padding-bottom: 3px;">'
'PANDUAN SISTEM LENGKAP: RETRAINING, ANOTASI &amp; LIVE COUNTING KARUNG KONVEYOR'
'</div>'
)
footer_tpl = (
'<div style="font-size: 7.5pt; color: #64748b; width: 100%; display: flex; '
'justify-content: space-between; padding: 0 18mm; font-family: Inter, \'Liberation Sans\', sans-serif; '
'border-top: 1px solid #e2e8f0; padding-top: 3px;">'
'<span>Dokumentasi Teknis Produksi v4.2.0</span>'
'<span>Halaman <span class="pageNumber"></span> dari <span class="totalPages"></span></span>'
'</div>'
)
with sync_playwright() as p:
browser = p.chromium.launch()
page = browser.new_page()
page.set_content(html_content, wait_until="networkidle")
page.pdf(
path=PDF_PATH,
format="A4",
print_background=True,
display_header_footer=True,
header_template=header_tpl,
footer_template=footer_tpl,
margin={
"top": "22mm",
"bottom": "22mm",
"left": "18mm",
"right": "18mm"
}
)
browser.close()
print(f"PDF successfully compiled to {PDF_PATH} ({os.path.getsize(PDF_PATH)} bytes)")
def main():
print("=== STARTING FULL PUBLICATION BUILD ===")
if not os.path.exists(MD_PATH):
raise FileNotFoundError(f"Source markdown not found: {MD_PATH}")
with open(MD_PATH, "r", encoding="utf-8") as f:
content = f.read()
blocks = parse_markdown_blocks(content)
print(f"Parsed {len(blocks)} markdown blocks.")
# 1. Generate FODT
generate_fodt(blocks)
# 2. Generate PDF
generate_html_and_pdf(blocks)
print("=== BUILD COMPLETED SUCCESSFULLY ===")
if __name__ == "__main__":
main()