import os
import glob
import re
import fitz
import shutil
import subprocess
from datetime import datetime
from markdown_pdf import MarkdownPdf, Section
# List of key technical terms to search and index across all documents
KEY_TERMS = [
"AI", "MySQL", "Zabbix", "Docker", "Streamlit", "Nginx", "Airflow",
"RAG", "Allergens", "Vitamins", "Minerals", "Clinical", "WSL", "Ollama",
"LLM", "Database", "Security", "Telemetry", "Backup", "Firewall", "SMTP"
]
def clean_header_text(text):
# Remove markdown links: [text](url) -> text
text = re.sub(r'\[([^\]]+)\]\([^)]+\)', r'\1', text)
# Remove bold/italic/code formatting
text = re.sub(r'[*_`]', '', text)
return text.strip()
def extract_headers(md_content):
headers = []
first_h1 = True
for line in md_content.splitlines():
if line.startswith('#'):
match = re.match(r'^(#{1,3})\s+(.+)$', line)
if match:
level = len(match.group(1))
t = match.group(2).strip()
# Skip the document main title (first H1)
if level == 1 and first_h1:
first_h1 = False
continue
headers.append((level, t))
return headers
def generate_references(md_content):
links = re.findall(r'\[([^\]]+)\]\((https?://[^\)]+)\)', md_content)
lines = ["\n\n## References\n"]
if links:
seen = set()
count = 1
for text, url in links:
if url not in seen:
seen.add(url)
clean_text = re.sub(r'[*_`]', '', text)
lines.append(f"{count}. **{clean_text}**: {url}")
count += 1
if count > 10:
break
else:
lines.append("1. **OpenFoodFacts Dataset & API Catalog**: Detailed food ingredients database. (https://world.openfoodfacts.org/)")
lines.append("2. **Ollama Local LLM Inference Engine**: Lightweight instruction-following llama3.2 runtimes. (https://ollama.com/)")
lines.append("3. **Zabbix Enterprise Telemetry and Monitoring**: System health and performance logging. (https://www.zabbix.com/)")
return "\n".join(lines)
def sanitize_name(name):
if not name:
return "Francois Lange"
name_lower = name.lower()
if "fran" in name_lower or "lange" in name_lower or "lanfr" in name_lower:
return "Francois Lange"
return name
def get_git_info_for_file(file_path):
try:
cmd = [
"git", "log", "-1",
"--date=format:%Y/%m/%d %H:%M:%S",
"--format=%an|%ae|%ad|%cn|%ce|%cd|%H|%D|%N",
"--", file_path
]
out = subprocess.check_output(cmd, stderr=subprocess.DEVNULL).decode('utf-8', errors='ignore').strip()
if out:
parts = out.split('|')
if len(parts) == 9:
parts[0] = sanitize_name(parts[0])
parts[3] = sanitize_name(parts[3])
return parts
except Exception:
pass
author_name = "Francois Lange"
try:
author_email = subprocess.check_output(["git", "config", "user.email"], stderr=subprocess.DEVNULL).decode('utf-8', errors='ignore').strip() or "lanfr144@school.lu"
except Exception:
author_email = "lanfr144@school.lu"
now_str = datetime.now().strftime("%Y/%m/%d %H:%M:%S")
return [author_name, author_email, now_str, author_name, author_email, now_str, "Not Committed Yet", "local", "none"]
def compile_pdf_document(md_file, title, md_content, toc_content, index_content, references_content, root_dir, user_css):
pdf = MarkdownPdf(optimize=True, plugins={"mermaid": {}})
base_name = os.path.basename(md_file)
# 1. Section 1: Cover Page
cover_html = f"""
{title}
Project Deliverable
| Course Class: | B1AI |
| Author: | François LANGE |
| Date: | 2026/06/19 |
"""
pdf.add_section(Section(cover_html, paper_size="A4", root=root_dir, borders=(36, 60, -36, -36)), user_css=user_css)
# 2. Section 2: Blank Page
blank_content = "
"
pdf.add_section(Section(blank_content, paper_size="A4", root=root_dir, borders=(36, 60, -36, -36)), user_css=user_css)
# 3. Section 3: Printed Table of Contents
pdf.add_section(Section(toc_content, paper_size="A4", root=root_dir, borders=(36, 60, -36, -36)), user_css=user_css)
# 4. Section 4: Main Content (with potential landscape splitting for project_report)
if base_name == 'project_report.md':
parts = md_content.split('## 2. Project File Catalog & Documentation')
if len(parts) == 2:
portrait_part1 = parts[0]
remaining = parts[1]
parts2 = remaining.split('## 3. Directory Structure Map')
if len(parts2) == 2:
landscape_part = '## 2. Project File Catalog & Documentation' + parts2[0]
portrait_part2 = '## 3. Directory Structure Map' + parts2[1]
pdf.add_section(Section(portrait_part1, paper_size="A4", root=root_dir, borders=(36, 60, -36, -36)), user_css=user_css)
pdf.add_section(Section(landscape_part, paper_size="A4-L", root=root_dir, borders=(36, 60, -36, -36)), user_css=user_css)
pdf.add_section(Section(portrait_part2 + "\n" + references_content, paper_size="A4", root=root_dir, borders=(36, 60, -36, -36)), user_css=user_css)
else:
pdf.add_section(Section(md_content + "\n" + references_content, paper_size="A4", root=root_dir, borders=(36, 60, -36, -36)), user_css=user_css)
else:
pdf.add_section(Section(md_content + "\n" + references_content, paper_size="A4", root=root_dir, borders=(36, 60, -36, -36)), user_css=user_css)
else:
pdf.add_section(Section(md_content + "\n" + references_content, paper_size="A4", root=root_dir, borders=(36, 60, -36, -36)), user_css=user_css)
# 5. Section 5: Printed Index
pdf.add_section(Section(index_content, paper_size="A4", root=root_dir, borders=(36, 60, -36, -36)), user_css=user_css)
return pdf
def generate_html_portal(root_dir, md_files):
portal_path = os.path.join(root_dir, 'index.html')
doc_metadata = {
"technical_document": "Core technical specification details of the local clinical Food AI including system architecture and data models.",
"distributed_deployment": "Installation topology guide for deploying the application stack across multiple hypervisors (WSL, Hyper-V, VirtualBox) or physical servers.",
"disaster_recovery_plan": "Recovery policies, backup strategies, and restore drills to ensure zero data loss during infrastructure outages.",
"Uninstall_Guide": "Clean uninstallation workflow to remove all database, cache, airflow, and container resources from physical hosts.",
"Operator_Installation_Guide": "Naked OS installation guide covering package installations, python dependencies, and native system configs.",
"project_report": "Comprehensive sprint report detailing system catalog, directory mappings, and final scrum delivery notes.",
"retro_planning": "Timeline planning showing daily milestones, task allocations, and sprint scheduling sheets.",
"User_Guide": "User manual outlining Streamlit login, meal planning, plate building, and chat assistant instructions.",
"zabbix_monitoring": "SNMP trap configurations, alert channels, and Zabbix server integration mappings.",
"Global_Index": "Unified global term index cross-referencing keywords across all documentation files."
}
cards_html = []
for md_file in sorted(md_files):
base = os.path.basename(md_file).replace('.md', '')
if "Header_Footer" in base:
continue
pdf_rel_path = f"docs/{base}.pdf"
md_rel_path = f"docs/{base}.md"
title = base.replace('_', ' ').replace('-', ' ').title()
if base == "project_report":
pdf_rel_path = "Project.pdf"
title = "Project Report"
elif base == "retro_planning":
pdf_rel_path = "Retro Planning.pdf"
title = "Retro Planning"
desc = doc_metadata.get(base, "Project documentation deliverable covering specifications and guides.")
cards_html.append(f"""
""")
cards_str = "\n".join(cards_html)
html_content = f"""
Local Food AI Documentation Portal
"""
with open(portal_path, 'w', encoding='utf-8') as f:
f.write(html_content)
print("Created index.html portal successfully.")
def main():
docs_dir = os.path.join(os.path.dirname(__file__), '..', 'docs')
md_files = glob.glob(os.path.join(docs_dir, '*.md'))
# Exclude Header/Footer files and previously created Global_Index if any
md_files = [f for f in md_files if "Header_Footer" not in os.path.basename(f) and "Global_Index" not in os.path.basename(f)]
if not md_files:
print("No markdown files found in docs/")
return
root_dir = os.path.abspath(os.path.join(os.path.dirname(__file__), '..')).replace('\\', '/')
user_css = """
* {
color: #1a1a1a !important;
}
body {
font-family: 'Helvetica', 'Arial', sans-serif !important;
color: #1a1a1a !important;
background-color: #ffffff !important;
}
h1, h2, h3, h4, h5, h6, h1 *, h2 *, h3 *, h4 *, h5 *, h6 * {
color: #000000 !important;
margin-top: 18px !important;
margin-bottom: 8px !important;
}
pre {
background-color: #f8f9fa !important;
border: 1px solid #e9ecef !important;
padding: 2px !important;
margin-top: 8px !important;
margin-bottom: 16px !important;
border-radius: 3px !important;
font-family: 'Courier New', 'Courier', monospace !important;
font-size: 10pt !important;
white-space: pre-wrap !important;
word-break: break-all !important;
}
pre code, pre * {
font-family: 'Courier New', 'Courier', monospace !important;
font-size: 10pt !important;
color: #212529 !important;
background-color: #f8f9fa !important;
white-space: pre-wrap !important;
word-break: break-all !important;
}
code {
font-family: 'Courier New', 'Courier', monospace !important;
font-size: 10pt !important;
color: #b02a37 !important;
background-color: #f8f9fa !important;
padding: 2px 4px !important;
border-radius: 3px !important;
white-space: pre-wrap !important;
word-break: break-all !important;
}
a, a * {
color: #0d6efd !important;
}
blockquote, blockquote * {
color: #555555 !important;
border-left: 4px solid #ccc !important;
padding-left: 10px !important;
}
table, tr, td, th, table * {
color: #1a1a1a !important;
border-color: #cccccc !important;
}
th {
background-color: #f2f2f2 !important;
}
ul, li {
list-style-type: disc !important;
}
"""
global_term_index = {} # Structure: { term: [ (doc_title, pdf_path, [page_numbers]) ] }
doc_data = {} # Structure: { md_file: { title, md_content, headers, references } }
# ==========================================
# PASS 1: Generate Draft PDFs and Find Pages
# ==========================================
print("=== STARTING DRAFT COMPILATION AND ANALYSIS PASS ===")
for md_file in md_files:
print(f"Analyzing {os.path.basename(md_file)}...")
with open(md_file, 'r', encoding='utf-8') as f:
md_content = f.read()
# Format replacements
base_name = os.path.basename(md_file)
info = get_git_info_for_file(md_file)
replacement = f"$Format:LocalFoodAI_lanfr144:generate_pdfs.py:%an:%ae:%ad:%cn:%ce:%cd:%H:%D:%N$"
pattern = r'\$Format:LocalFoodAI_lanfr144:generate_pdfs.py:%an:%ae:%ad:%cn:%ce:%cd:%H:%D:%N$'
md_content = re.sub(pattern, replacement, md_content)
md_content = re.sub(r'file:///.*?/docs/([a-zA-Z0-9_-]+)\.md', r'\1.pdf', md_content, flags=re.IGNORECASE)
md_content = re.sub(r'file:///.*?/Food/([a-zA-Z0-9_.-]+)', r'../\1', md_content, flags=re.IGNORECASE)
md_content = re.sub(r'(```[a-zA-Z0-9_-]*\n[\s\S]*?\n```)', r'\1\n\n', md_content)
md_content = re.sub(r'\]\((?!http|https)([a-zA-Z0-9_./-]+)\.md(#[a-zA-Z0-9_-]+)?\)', r'](\1.pdf\2)', md_content, flags=re.IGNORECASE)
# Title parsing
title_match = re.search(r'^#\s+(.+)$', md_content, re.MULTILINE)
if title_match:
doc_title = title_match.group(1).strip()
doc_title = re.sub(r'[*_`#]', '', doc_title).strip()
else:
doc_title = base_name.replace('.md', '').replace('_', ' ').title()
headers = extract_headers(md_content)
references = generate_references(md_content)
# Build placeholders for TOC and Index in the draft
toc_placeholder = "# Table of Contents\n\n"
for level, t in headers:
indent = " " * (level - 1)
toc_placeholder += f"{indent}- {t} .................................................... Page 00\n"
index_placeholder = "## Index\n\n"
for term in KEY_TERMS:
index_placeholder += f"- **{term}**: Page 00\n"
doc_data[md_file] = {
"title": doc_title,
"md_content": md_content,
"headers": headers,
"references": references
}
try:
# Compile draft PDF
pdf = compile_pdf_document(
md_file, doc_title, md_content, toc_placeholder,
index_placeholder, references, root_dir, user_css
)
draft_pdf_file = md_file.replace('.md', '_draft.pdf')
pdf.save(draft_pdf_file)
pdf.temp_files.clean()
# Analyze draft PDF using fitz
doc = fitz.open(draft_pdf_file)
# 1. Map headers to printed page numbers
page_map = {}
for level, raw_t in headers:
clean_t = clean_header_text(raw_t)
found_page = None
for page_idx in range(3, len(doc)): # Main content starts on page index 3 (Page 2)
page = doc[page_idx]
rects = page.search_for(clean_t)
if rects:
found_page = page_idx - 1 # printed Page 1 is index 2 (TOC page)
break
if found_page is None:
found_page = 2 # default fallback
page_map[raw_t] = found_page
# 2. Map key terms to printed page numbers
term_pages = {}
for term in KEY_TERMS:
matches = []
for page_idx in range(2, len(doc)): # TOC page (index 2) onwards
page = doc[page_idx]
rects = page.search_for(term)
if rects:
matches.append(page_idx - 1)
if matches:
term_pages[term] = matches
# Populate global index
if term not in global_term_index:
global_term_index[term] = []
global_term_index[term].append((doc_title, md_file.replace('.md', '.pdf'), matches))
doc_data[md_file]["page_map"] = page_map
doc_data[md_file]["term_pages"] = term_pages
doc.close()
# Clean up draft PDF
if os.path.exists(draft_pdf_file):
os.remove(draft_pdf_file)
except Exception as err:
print(f"Draft analysis failed for {os.path.basename(md_file)}: {err}")
# ==========================================
# PASS 2: Create Global Index and Compile Finals
# ==========================================
print("\n=== COMPILING GLOBAL INDEX DOCUMENT ===")
global_index_md_path = os.path.join(docs_dir, 'Global_Index.md')
global_index_pdf_path = os.path.join(docs_dir, 'Global_Index.pdf')
# Generate Global_Index.md content
global_lines = [
"# Global Project Index\n",
"This document provides a unified cross-reference index of all key technical terms and concepts across the LocalFoodAI documentation suite. Click on any document link to open it at the specific page in your viewer.\n",
"## Index of Terms\n"
]
for term in sorted(global_term_index.keys()):
occurrences = global_term_index[term]
global_lines.append(f"### {term}\n")
for doc_title, pdf_path, pages in occurrences:
pages_str = ", ".join([f"Page {p}" for p in pages])
pdf_rel = os.path.basename(pdf_path)
first_page = pages[0]
# Format link to open PDF on exact page index
global_lines.append(f"- **{doc_title}**: {pages_str} | [Open PDF at Page {first_page}]({pdf_rel}#page={first_page + 1})")
global_lines.append("")
with open(global_index_md_path, 'w', encoding='utf-8') as f:
f.write("\n".join(global_lines))
print("Saved docs/Global_Index.md")
# Add Global_Index.md to the compilation files list
md_files.append(global_index_md_path)
# Generate draft analysis for Global_Index.md as well
with open(global_index_md_path, 'r', encoding='utf-8') as f:
global_md_content = f.read()
global_headers = extract_headers(global_md_content)
global_references = generate_references(global_md_content)
toc_placeholder = "# Table of Contents\n\n"
for level, t in global_headers:
indent = " " * (level - 1)
toc_placeholder += f"{indent}- {t} .................................................... Page 00\n"
index_placeholder = "## Index\n\n"
for term in KEY_TERMS:
index_placeholder += f"- **{term}**: Page 00\n"
doc_data[global_index_md_path] = {
"title": "Global Project Index",
"md_content": global_md_content,
"headers": global_headers,
"references": global_references
}
# Run a quick draft pass for Global Index itself to find header and index pages
try:
pdf = compile_pdf_document(
global_index_md_path, "Global Project Index", global_md_content,
toc_placeholder, index_placeholder, global_references, root_dir, user_css
)
draft_global_pdf = global_index_pdf_path.replace('.pdf', '_draft.pdf')
pdf.save(draft_global_pdf)
pdf.temp_files.clean()
doc = fitz.open(draft_global_pdf)
global_page_map = {}
for level, raw_t in global_headers:
clean_t = clean_header_text(raw_t)
found_page = None
for page_idx in range(3, len(doc)):
page = doc[page_idx]
rects = page.search_for(clean_t)
if rects:
found_page = page_idx - 1
break
if found_page is None:
found_page = 2
global_page_map[raw_t] = found_page
global_term_pages = {}
for term in KEY_TERMS:
matches = []
for page_idx in range(2, len(doc)):
page = doc[page_idx]
rects = page.search_for(term)
if rects:
matches.append(page_idx - 1)
if matches:
global_term_pages[term] = matches
doc_data[global_index_md_path]["page_map"] = global_page_map
doc_data[global_index_md_path]["term_pages"] = global_term_pages
doc.close()
if os.path.exists(draft_global_pdf):
os.remove(draft_global_pdf)
except Exception as err:
print(f"Draft analysis failed for Global_Index.md: {err}")
# ==========================================
# PASS 3: Generate Final PDFs
# ==========================================
print("\n=== COMPILING FINAL GENERATED PDF FILES ===")
for md_file in md_files:
pdf_file = md_file.replace('.md', '.pdf')
base_name = os.path.basename(md_file)
data = doc_data.get(md_file)
if not data:
continue
title = data["title"]
md_content = data["md_content"]
headers = data["headers"]
references = data["references"]
page_map = data.get("page_map", {})
term_pages = data.get("term_pages", {})
# 1. Construct final printed TOC
toc_lines = ["# Table of Contents\n\n"]
for level, t in headers:
indent = " " * (level - 1)
p_num = page_map.get(t, 2)
toc_lines.append(f"{indent}- {t} .................................................... Page {p_num}")
toc_content = "\n".join(toc_lines)
# 2. Construct final printed Index
index_lines = ["## Index\n\n"]
has_index_terms = False
for term in KEY_TERMS:
if term in term_pages:
has_index_terms = True
pages_str = ", ".join([f"Page {p}" for p in term_pages[term]])
index_lines.append(f"- **{term}**: {pages_str}")
if not has_index_terms:
index_lines.append("- No key terms found in this document.")
index_content = "\n".join(index_lines)
try:
pdf = compile_pdf_document(
md_file, title, md_content, toc_content,
index_content, references, root_dir, user_css
)
doc = pdf._make_doc()
total_pages = len(doc)
am_path = os.path.join(docs_dir, 'am.png')
bts_path = os.path.join(docs_dir, 'Bts.png')
width_am, height_am = 146.5, 24.5
if os.path.exists(am_path):
try:
pix_am = fitz.Pixmap(am_path)
width_am = pix_am.width * 0.1
height_am = pix_am.height * 0.1
except Exception: pass
width_bts, height_bts = 67.1, 36.8
if os.path.exists(bts_path):
try:
pix_bts = fitz.Pixmap(bts_path)
width_bts = pix_bts.width * 0.1
height_bts = pix_bts.height * 0.1
except Exception: pass
for page_idx in range(total_pages):
# COVER & BLANK page should not contain headers, footers, or logos
if page_idx in [0, 1]:
continue
page = doc[page_idx]
width = page.rect.width
height = page.rect.height
# Header layout
if os.path.exists(am_path):
image_rect = fitz.Rect(48, 12, 48 + width_am, 12 + height_am)
page.insert_image(image_rect, filename=am_path, overlay=False)
if os.path.exists(bts_path):
bts_rect = fitz.Rect(width - 48 - width_bts, 8, width - 48, 8 + height_bts)
page.insert_image(bts_rect, filename=bts_path, overlay=False)
# Title text
fontsize = 8
fontname = "helv"
m_width = fitz.get_text_length(title, fontname=fontname, fontsize=fontsize)
space_start = 48 + width_am
space_end = width - 48 - width_bts
title_x = space_start + (space_end - space_start - m_width) / 2
page.insert_text(
fitz.Point(title_x, 26),
title,
fontname=fontname,
fontsize=fontsize,
color=(0.5, 0.5, 0.5)
)
# Footer layout (Page numbers start counting at TOC as Page 1)
l_footer = "lanfr144"
footer_text = f"Page {page_idx - 1} of {total_pages - 2}"
f_width = fitz.get_text_length(footer_text, fontname=fontname, fontsize=fontsize)
page.insert_text(
fitz.Point(48, height - 22),
l_footer,
fontname=fontname,
fontsize=fontsize,
color=(0.5, 0.5, 0.5)
)
page.insert_text(
fitz.Point((width - f_width) / 2, height - 22),
footer_text,
fontname=fontname,
fontsize=fontsize,
color=(0.5, 0.5, 0.5)
)
r_footer = "DOPRO1"
r_footer_width = fitz.get_text_length(r_footer, fontname=fontname, fontsize=fontsize)
page.insert_text(
fitz.Point(width - 48 - r_footer_width, height - 22),
r_footer,
fontname=fontname,
fontsize=fontsize,
color=(0.5, 0.5, 0.5)
)
doc.save(pdf_file, clean=True, garbage=3, deflate=True)
doc.close()
pdf.temp_files.clean()
print(f"Saved {os.path.basename(pdf_file)}")
# Copy to workspace root if applicable
if base_name == 'project_report.md':
dest = os.path.join(root_dir, 'Project.pdf')
if os.path.exists(dest): os.remove(dest)
shutil.copy2(pdf_file, dest)
print("Successfully updated root Project.pdf")
elif base_name == 'retro_planning.md':
dest = os.path.join(root_dir, 'Retro Planning.pdf')
if os.path.exists(dest): os.remove(dest)
shutil.copy2(pdf_file, dest)
print("Successfully updated root Retro Planning.pdf")
except Exception as e:
print(f"WARNING: Could not save {os.path.basename(pdf_file)}. File might be locked or open in a viewer. Error: {e}")
# Generate HTML catalog portal in the workspace root
generate_html_portal(root_dir, md_files)
if __name__ == "__main__":
main()