generate_pdfs.py 32 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784
  1. import os
  2. import glob
  3. import re
  4. import fitz
  5. import shutil
  6. import subprocess
  7. from datetime import datetime
  8. from markdown_pdf import MarkdownPdf, Section
  9. # List of key technical terms to search and index across all documents
  10. KEY_TERMS = [
  11. "AI", "MySQL", "Zabbix", "Docker", "Streamlit", "Nginx", "Airflow",
  12. "RAG", "Allergens", "Vitamins", "Minerals", "Clinical", "WSL", "Ollama",
  13. "LLM", "Database", "Security", "Telemetry", "Backup", "Firewall", "SMTP"
  14. ]
  15. def clean_header_text(text):
  16. # Remove markdown links: [text](url) -> text
  17. text = re.sub(r'\[([^\]]+)\]\([^)]+\)', r'\1', text)
  18. # Remove bold/italic/code formatting
  19. text = re.sub(r'[*_`]', '', text)
  20. return text.strip()
  21. def extract_headers(md_content):
  22. headers = []
  23. first_h1 = True
  24. for line in md_content.splitlines():
  25. if line.startswith('#'):
  26. match = re.match(r'^(#{1,3})\s+(.+)$', line)
  27. if match:
  28. level = len(match.group(1))
  29. t = match.group(2).strip()
  30. # Skip the document main title (first H1)
  31. if level == 1 and first_h1:
  32. first_h1 = False
  33. continue
  34. headers.append((level, t))
  35. return headers
  36. def generate_references(md_content):
  37. links = re.findall(r'\[([^\]]+)\]\((https?://[^\)]+)\)', md_content)
  38. lines = ["\n\n## References\n"]
  39. if links:
  40. seen = set()
  41. count = 1
  42. for text, url in links:
  43. if url not in seen:
  44. seen.add(url)
  45. clean_text = re.sub(r'[*_`]', '', text)
  46. lines.append(f"{count}. **{clean_text}**: {url}")
  47. count += 1
  48. if count > 10:
  49. break
  50. else:
  51. lines.append("1. **OpenFoodFacts Dataset & API Catalog**: Detailed food ingredients database. (https://world.openfoodfacts.org/)")
  52. lines.append("2. **Ollama Local LLM Inference Engine**: Lightweight instruction-following llama3.2 runtimes. (https://ollama.com/)")
  53. lines.append("3. **Zabbix Enterprise Telemetry and Monitoring**: System health and performance logging. (https://www.zabbix.com/)")
  54. return "\n".join(lines)
  55. def sanitize_name(name):
  56. if not name:
  57. return "Francois Lange"
  58. name_lower = name.lower()
  59. if "fran" in name_lower or "lange" in name_lower or "lanfr" in name_lower:
  60. return "Francois Lange"
  61. return name
  62. def get_git_info_for_file(file_path):
  63. try:
  64. cmd = [
  65. "git", "log", "-1",
  66. "--date=format:%Y/%m/%d %H:%M:%S",
  67. "--format=%an|%ae|%ad|%cn|%ce|%cd|%H|%D|%N",
  68. "--", file_path
  69. ]
  70. out = subprocess.check_output(cmd, stderr=subprocess.DEVNULL).decode('utf-8', errors='ignore').strip()
  71. if out:
  72. parts = out.split('|')
  73. if len(parts) == 9:
  74. parts[0] = sanitize_name(parts[0])
  75. parts[3] = sanitize_name(parts[3])
  76. return parts
  77. except Exception:
  78. pass
  79. author_name = "Francois Lange"
  80. try:
  81. author_email = subprocess.check_output(["git", "config", "user.email"], stderr=subprocess.DEVNULL).decode('utf-8', errors='ignore').strip() or "lanfr144@school.lu"
  82. except Exception:
  83. author_email = "lanfr144@school.lu"
  84. now_str = datetime.now().strftime("%Y/%m/%d %H:%M:%S")
  85. return [author_name, author_email, now_str, author_name, author_email, now_str, "Not Committed Yet", "local", "none"]
  86. def compile_pdf_document(md_file, title, md_content, toc_content, index_content, references_content, root_dir, user_css):
  87. pdf = MarkdownPdf(optimize=True, plugins={"mermaid": {}})
  88. base_name = os.path.basename(md_file)
  89. # 1. Section 1: Cover Page
  90. cover_html = f"""
  91. <div style="text-align: center; font-family: 'Helvetica', 'Arial', sans-serif; padding-top: 150px; padding-bottom: 150px;">
  92. <h1 style="font-size: 28pt; font-weight: bold; margin-bottom: 20px; color: #000000; line-height: 1.2;">{title}</h1>
  93. <p style="font-size: 14pt; color: #666666; margin-top: 10px; margin-bottom: 150px; letter-spacing: 1px; text-transform: uppercase;">Project Deliverable</p>
  94. <div style="border-top: 2px solid #eaeaea; width: 80%; margin: 0 auto 50px auto;"></div>
  95. <table style="margin: 0 auto; text-align: left; font-size: 12pt; border: none; width: 60%; line-height: 2.0; border-collapse: collapse;">
  96. <tr style="border: none;"><td style="font-weight: bold; color: #555555; padding: 5px; border: none; width: 40%;">Course Class:</td><td style="color: #111111; padding: 5px; border: none;">B1AI</td></tr>
  97. <tr style="border: none;"><td style="font-weight: bold; color: #555555; padding: 5px; border: none;">Author:</td><td style="color: #111111; padding: 5px; border: none;">François LANGE</td></tr>
  98. <tr style="border: none;"><td style="font-weight: bold; color: #555555; padding: 5px; border: none;">Date:</td><td style="color: #111111; padding: 5px; border: none;">2026/06/19</td></tr>
  99. </table>
  100. </div>
  101. """
  102. pdf.add_section(Section(cover_html, paper_size="A4", root=root_dir, borders=(36, 60, -36, -36)), user_css=user_css)
  103. # 2. Section 2: Blank Page
  104. blank_content = "<div style='height: 1000px;'>&nbsp;</div>"
  105. pdf.add_section(Section(blank_content, paper_size="A4", root=root_dir, borders=(36, 60, -36, -36)), user_css=user_css)
  106. # 3. Section 3: Printed Table of Contents
  107. pdf.add_section(Section(toc_content, paper_size="A4", root=root_dir, borders=(36, 60, -36, -36)), user_css=user_css)
  108. # 4. Section 4: Main Content (with potential landscape splitting for project_report)
  109. if base_name == 'project_report.md':
  110. parts = md_content.split('## 2. Project File Catalog & Documentation')
  111. if len(parts) == 2:
  112. portrait_part1 = parts[0]
  113. remaining = parts[1]
  114. parts2 = remaining.split('## 3. Directory Structure Map')
  115. if len(parts2) == 2:
  116. landscape_part = '## 2. Project File Catalog & Documentation' + parts2[0]
  117. portrait_part2 = '## 3. Directory Structure Map' + parts2[1]
  118. pdf.add_section(Section(portrait_part1, paper_size="A4", root=root_dir, borders=(36, 60, -36, -36)), user_css=user_css)
  119. pdf.add_section(Section(landscape_part, paper_size="A4-L", root=root_dir, borders=(36, 60, -36, -36)), user_css=user_css)
  120. pdf.add_section(Section(portrait_part2 + "\n" + references_content, paper_size="A4", root=root_dir, borders=(36, 60, -36, -36)), user_css=user_css)
  121. else:
  122. pdf.add_section(Section(md_content + "\n" + references_content, paper_size="A4", root=root_dir, borders=(36, 60, -36, -36)), user_css=user_css)
  123. else:
  124. pdf.add_section(Section(md_content + "\n" + references_content, paper_size="A4", root=root_dir, borders=(36, 60, -36, -36)), user_css=user_css)
  125. else:
  126. pdf.add_section(Section(md_content + "\n" + references_content, paper_size="A4", root=root_dir, borders=(36, 60, -36, -36)), user_css=user_css)
  127. # 5. Section 5: Printed Index
  128. pdf.add_section(Section(index_content, paper_size="A4", root=root_dir, borders=(36, 60, -36, -36)), user_css=user_css)
  129. return pdf
  130. def generate_html_portal(root_dir, md_files):
  131. portal_path = os.path.join(root_dir, 'index.html')
  132. doc_metadata = {
  133. "technical_document": "Core technical specification details of the local clinical Food AI including system architecture and data models.",
  134. "distributed_deployment": "Installation topology guide for deploying the application stack across multiple hypervisors (WSL, Hyper-V, VirtualBox) or physical servers.",
  135. "disaster_recovery_plan": "Recovery policies, backup strategies, and restore drills to ensure zero data loss during infrastructure outages.",
  136. "Uninstall_Guide": "Clean uninstallation workflow to remove all database, cache, airflow, and container resources from physical hosts.",
  137. "Operator_Installation_Guide": "Naked OS installation guide covering package installations, python dependencies, and native system configs.",
  138. "project_report": "Comprehensive sprint report detailing system catalog, directory mappings, and final scrum delivery notes.",
  139. "retro_planning": "Timeline planning showing daily milestones, task allocations, and sprint scheduling sheets.",
  140. "User_Guide": "User manual outlining Streamlit login, meal planning, plate building, and chat assistant instructions.",
  141. "zabbix_monitoring": "SNMP trap configurations, alert channels, and Zabbix server integration mappings.",
  142. "Global_Index": "Unified global term index cross-referencing keywords across all documentation files."
  143. }
  144. cards_html = []
  145. for md_file in sorted(md_files):
  146. base = os.path.basename(md_file).replace('.md', '')
  147. if "Header_Footer" in base:
  148. continue
  149. pdf_rel_path = f"docs/{base}.pdf"
  150. md_rel_path = f"docs/{base}.md"
  151. title = base.replace('_', ' ').replace('-', ' ').title()
  152. if base == "project_report":
  153. pdf_rel_path = "Project.pdf"
  154. title = "Project Report"
  155. elif base == "retro_planning":
  156. pdf_rel_path = "Retro Planning.pdf"
  157. title = "Retro Planning"
  158. desc = doc_metadata.get(base, "Project documentation deliverable covering specifications and guides.")
  159. cards_html.append(f"""
  160. <div class="card">
  161. <div class="card-body">
  162. <div>
  163. <h3 class="card-title">{title}</h3>
  164. <p class="card-text">{desc}</p>
  165. </div>
  166. <div class="card-links">
  167. <a href="{pdf_rel_path}" class="btn btn-primary" target="_blank">📄 View PDF</a>
  168. <a href="{md_rel_path}" class="btn btn-secondary" target="_blank">📝 View Markdown</a>
  169. </div>
  170. </div>
  171. </div>
  172. """)
  173. cards_str = "\n".join(cards_html)
  174. html_content = f"""<!DOCTYPE html>
  175. <html lang="en">
  176. <head>
  177. <meta charset="UTF-8">
  178. <meta name="viewport" content="width=device-width, initial-scale=1.0">
  179. <title>Local Food AI Documentation Portal</title>
  180. <style>
  181. body {{
  182. font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
  183. background-color: #f4f7f6;
  184. margin: 0;
  185. padding: 0;
  186. color: #333;
  187. }}
  188. .header {{
  189. background: linear-gradient(135deg, #1b263b, #0d1b2a);
  190. color: white;
  191. padding: 40px 20px;
  192. text-align: center;
  193. box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
  194. }}
  195. .header h1 {{
  196. margin: 0;
  197. font-size: 28pt;
  198. font-weight: 600;
  199. }}
  200. .header p {{
  201. margin: 10px 0 0 0;
  202. font-size: 14pt;
  203. color: #a3b18a;
  204. }}
  205. .container {{
  206. max-width: 1200px;
  207. margin: 40px auto;
  208. padding: 0 20px;
  209. }}
  210. .class-meta {{
  211. background-color: white;
  212. padding: 20px;
  213. border-radius: 8px;
  214. margin-bottom: 30px;
  215. box-shadow: 0 2px 4px rgba(0,0,0,0.05);
  216. display: flex;
  217. justify-content: space-around;
  218. flex-wrap: wrap;
  219. font-size: 11pt;
  220. }}
  221. .class-meta div {{
  222. margin: 10px;
  223. }}
  224. .grid {{
  225. display: grid;
  226. grid-template-columns: repeat(auto-fill, minmax(340px, 1fr));
  227. gap: 25px;
  228. }}
  229. .card {{
  230. background-color: white;
  231. border-radius: 8px;
  232. box-shadow: 0 4px 15px rgba(0, 0, 0, 0.05);
  233. transition: transform 0.2s ease, box-shadow 0.2s ease;
  234. overflow: hidden;
  235. border: 1px solid #e9ecef;
  236. }}
  237. .card:hover {{
  238. transform: translateY(-5px);
  239. box-shadow: 0 8px 25px rgba(0, 0, 0, 0.1);
  240. }}
  241. .card-body {{
  242. padding: 25px;
  243. display: flex;
  244. flex-direction: column;
  245. height: 180px;
  246. justify-content: space-between;
  247. }}
  248. .card-title {{
  249. margin: 0 0 10px 0;
  250. font-size: 15pt;
  251. color: #1b263b;
  252. }}
  253. .card-text {{
  254. font-size: 10pt;
  255. color: #666;
  256. margin: 0 0 20px 0;
  257. line-height: 1.5;
  258. }}
  259. .card-links {{
  260. display: flex;
  261. gap: 10px;
  262. }}
  263. .btn {{
  264. display: inline-block;
  265. padding: 10px 15px;
  266. font-size: 9.5pt;
  267. text-decoration: none;
  268. border-radius: 5px;
  269. text-align: center;
  270. font-weight: 600;
  271. flex: 1;
  272. }}
  273. .btn-primary {{
  274. background-color: #0077b6;
  275. color: white;
  276. }}
  277. .btn-primary:hover {{
  278. background-color: #0096c7;
  279. }}
  280. .btn-secondary {{
  281. background-color: #eaeef1;
  282. color: #495057;
  283. }}
  284. .btn-secondary:hover {{
  285. background-color: #dee2e6;
  286. }}
  287. .footer {{
  288. text-align: center;
  289. padding: 40px;
  290. font-size: 10pt;
  291. color: #888;
  292. }}
  293. </style>
  294. </head>
  295. <body>
  296. <div class="header">
  297. <h1>Local Food AI</h1>
  298. <p>Unified Project Documentation Portal</p>
  299. </div>
  300. <div class="container">
  301. <div class="class-meta">
  302. <div><strong>Class Course:</strong> B1AI</div>
  303. <div><strong>Student Author:</strong> François LANGE</div>
  304. <div><strong>Academic Year:</strong> 2025/2026</div>
  305. </div>
  306. <div class="grid">
  307. {cards_str}
  308. </div>
  309. </div>
  310. <div class="footer">
  311. &copy; 2026 François LANGE (Class B1AI). All rights reserved.
  312. </div>
  313. </body>
  314. </html>
  315. """
  316. with open(portal_path, 'w', encoding='utf-8') as f:
  317. f.write(html_content)
  318. print("Created index.html portal successfully.")
  319. def main():
  320. docs_dir = os.path.join(os.path.dirname(__file__), '..', 'docs')
  321. md_files = glob.glob(os.path.join(docs_dir, '*.md'))
  322. # Exclude Header/Footer files and previously created Global_Index if any
  323. 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)]
  324. if not md_files:
  325. print("No markdown files found in docs/")
  326. return
  327. root_dir = os.path.abspath(os.path.join(os.path.dirname(__file__), '..')).replace('\\', '/')
  328. user_css = """
  329. * {
  330. color: #1a1a1a !important;
  331. }
  332. body {
  333. font-family: 'Helvetica', 'Arial', sans-serif !important;
  334. color: #1a1a1a !important;
  335. background-color: #ffffff !important;
  336. }
  337. h1, h2, h3, h4, h5, h6, h1 *, h2 *, h3 *, h4 *, h5 *, h6 * {
  338. color: #000000 !important;
  339. margin-top: 18px !important;
  340. margin-bottom: 8px !important;
  341. }
  342. pre {
  343. background-color: #f8f9fa !important;
  344. border: 1px solid #e9ecef !important;
  345. padding: 2px !important;
  346. margin-top: 8px !important;
  347. margin-bottom: 16px !important;
  348. border-radius: 3px !important;
  349. font-family: 'Courier New', 'Courier', monospace !important;
  350. font-size: 10pt !important;
  351. white-space: pre-wrap !important;
  352. word-break: break-all !important;
  353. }
  354. pre code, pre * {
  355. font-family: 'Courier New', 'Courier', monospace !important;
  356. font-size: 10pt !important;
  357. color: #212529 !important;
  358. background-color: #f8f9fa !important;
  359. white-space: pre-wrap !important;
  360. word-break: break-all !important;
  361. }
  362. code {
  363. font-family: 'Courier New', 'Courier', monospace !important;
  364. font-size: 10pt !important;
  365. color: #b02a37 !important;
  366. background-color: #f8f9fa !important;
  367. padding: 2px 4px !important;
  368. border-radius: 3px !important;
  369. white-space: pre-wrap !important;
  370. word-break: break-all !important;
  371. }
  372. a, a * {
  373. color: #0d6efd !important;
  374. }
  375. blockquote, blockquote * {
  376. color: #555555 !important;
  377. border-left: 4px solid #ccc !important;
  378. padding-left: 10px !important;
  379. }
  380. table, tr, td, th, table * {
  381. color: #1a1a1a !important;
  382. border-color: #cccccc !important;
  383. }
  384. th {
  385. background-color: #f2f2f2 !important;
  386. }
  387. ul, li {
  388. list-style-type: disc !important;
  389. }
  390. """
  391. global_term_index = {} # Structure: { term: [ (doc_title, pdf_path, [page_numbers]) ] }
  392. doc_data = {} # Structure: { md_file: { title, md_content, headers, references } }
  393. # ==========================================
  394. # PASS 1: Generate Draft PDFs and Find Pages
  395. # ==========================================
  396. print("=== STARTING DRAFT COMPILATION AND ANALYSIS PASS ===")
  397. for md_file in md_files:
  398. print(f"Analyzing {os.path.basename(md_file)}...")
  399. with open(md_file, 'r', encoding='utf-8') as f:
  400. md_content = f.read()
  401. # Format replacements
  402. base_name = os.path.basename(md_file)
  403. info = get_git_info_for_file(md_file)
  404. replacement = f"$Format:LocalFoodAI_lanfr144:generate_pdfs.py:%an:%ae:%ad:%cn:%ce:%cd:%H:%D:%N$"
  405. pattern = r'\$Format:LocalFoodAI_lanfr144:generate_pdfs.py:%an:%ae:%ad:%cn:%ce:%cd:%H:%D:%N$'
  406. md_content = re.sub(pattern, replacement, md_content)
  407. md_content = re.sub(r'file:///.*?/docs/([a-zA-Z0-9_-]+)\.md', r'\1.pdf', md_content, flags=re.IGNORECASE)
  408. md_content = re.sub(r'file:///.*?/Food/([a-zA-Z0-9_.-]+)', r'../\1', md_content, flags=re.IGNORECASE)
  409. md_content = re.sub(r'(```[a-zA-Z0-9_-]*\n[\s\S]*?\n```)', r'\1\n\n', md_content)
  410. 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)
  411. # Title parsing
  412. title_match = re.search(r'^#\s+(.+)$', md_content, re.MULTILINE)
  413. if title_match:
  414. doc_title = title_match.group(1).strip()
  415. doc_title = re.sub(r'[*_`#]', '', doc_title).strip()
  416. else:
  417. doc_title = base_name.replace('.md', '').replace('_', ' ').title()
  418. headers = extract_headers(md_content)
  419. references = generate_references(md_content)
  420. # Build placeholders for TOC and Index in the draft
  421. toc_placeholder = "# Table of Contents\n\n"
  422. for level, t in headers:
  423. indent = " " * (level - 1)
  424. toc_placeholder += f"{indent}- {t} .................................................... Page 00\n"
  425. index_placeholder = "## Index\n\n"
  426. for term in KEY_TERMS:
  427. index_placeholder += f"- **{term}**: Page 00\n"
  428. doc_data[md_file] = {
  429. "title": doc_title,
  430. "md_content": md_content,
  431. "headers": headers,
  432. "references": references
  433. }
  434. try:
  435. # Compile draft PDF
  436. pdf = compile_pdf_document(
  437. md_file, doc_title, md_content, toc_placeholder,
  438. index_placeholder, references, root_dir, user_css
  439. )
  440. draft_pdf_file = md_file.replace('.md', '_draft.pdf')
  441. pdf.save(draft_pdf_file)
  442. pdf.temp_files.clean()
  443. # Analyze draft PDF using fitz
  444. doc = fitz.open(draft_pdf_file)
  445. # 1. Map headers to printed page numbers
  446. page_map = {}
  447. for level, raw_t in headers:
  448. clean_t = clean_header_text(raw_t)
  449. found_page = None
  450. for page_idx in range(3, len(doc)): # Main content starts on page index 3 (Page 2)
  451. page = doc[page_idx]
  452. rects = page.search_for(clean_t)
  453. if rects:
  454. found_page = page_idx - 1 # printed Page 1 is index 2 (TOC page)
  455. break
  456. if found_page is None:
  457. found_page = 2 # default fallback
  458. page_map[raw_t] = found_page
  459. # 2. Map key terms to printed page numbers
  460. term_pages = {}
  461. for term in KEY_TERMS:
  462. matches = []
  463. for page_idx in range(2, len(doc)): # TOC page (index 2) onwards
  464. page = doc[page_idx]
  465. rects = page.search_for(term)
  466. if rects:
  467. matches.append(page_idx - 1)
  468. if matches:
  469. term_pages[term] = matches
  470. # Populate global index
  471. if term not in global_term_index:
  472. global_term_index[term] = []
  473. global_term_index[term].append((doc_title, md_file.replace('.md', '.pdf'), matches))
  474. doc_data[md_file]["page_map"] = page_map
  475. doc_data[md_file]["term_pages"] = term_pages
  476. doc.close()
  477. # Clean up draft PDF
  478. if os.path.exists(draft_pdf_file):
  479. os.remove(draft_pdf_file)
  480. except Exception as err:
  481. print(f"Draft analysis failed for {os.path.basename(md_file)}: {err}")
  482. # ==========================================
  483. # PASS 2: Create Global Index and Compile Finals
  484. # ==========================================
  485. print("\n=== COMPILING GLOBAL INDEX DOCUMENT ===")
  486. global_index_md_path = os.path.join(docs_dir, 'Global_Index.md')
  487. global_index_pdf_path = os.path.join(docs_dir, 'Global_Index.pdf')
  488. # Generate Global_Index.md content
  489. global_lines = [
  490. "# Global Project Index\n",
  491. "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",
  492. "## Index of Terms\n"
  493. ]
  494. for term in sorted(global_term_index.keys()):
  495. occurrences = global_term_index[term]
  496. global_lines.append(f"### {term}\n")
  497. for doc_title, pdf_path, pages in occurrences:
  498. pages_str = ", ".join([f"Page {p}" for p in pages])
  499. pdf_rel = os.path.basename(pdf_path)
  500. first_page = pages[0]
  501. # Format link to open PDF on exact page index
  502. global_lines.append(f"- **{doc_title}**: {pages_str} | [Open PDF at Page {first_page}]({pdf_rel}#page={first_page + 1})")
  503. global_lines.append("")
  504. with open(global_index_md_path, 'w', encoding='utf-8') as f:
  505. f.write("\n".join(global_lines))
  506. print("Saved docs/Global_Index.md")
  507. # Add Global_Index.md to the compilation files list
  508. md_files.append(global_index_md_path)
  509. # Generate draft analysis for Global_Index.md as well
  510. with open(global_index_md_path, 'r', encoding='utf-8') as f:
  511. global_md_content = f.read()
  512. global_headers = extract_headers(global_md_content)
  513. global_references = generate_references(global_md_content)
  514. toc_placeholder = "# Table of Contents\n\n"
  515. for level, t in global_headers:
  516. indent = " " * (level - 1)
  517. toc_placeholder += f"{indent}- {t} .................................................... Page 00\n"
  518. index_placeholder = "## Index\n\n"
  519. for term in KEY_TERMS:
  520. index_placeholder += f"- **{term}**: Page 00\n"
  521. doc_data[global_index_md_path] = {
  522. "title": "Global Project Index",
  523. "md_content": global_md_content,
  524. "headers": global_headers,
  525. "references": global_references
  526. }
  527. # Run a quick draft pass for Global Index itself to find header and index pages
  528. try:
  529. pdf = compile_pdf_document(
  530. global_index_md_path, "Global Project Index", global_md_content,
  531. toc_placeholder, index_placeholder, global_references, root_dir, user_css
  532. )
  533. draft_global_pdf = global_index_pdf_path.replace('.pdf', '_draft.pdf')
  534. pdf.save(draft_global_pdf)
  535. pdf.temp_files.clean()
  536. doc = fitz.open(draft_global_pdf)
  537. global_page_map = {}
  538. for level, raw_t in global_headers:
  539. clean_t = clean_header_text(raw_t)
  540. found_page = None
  541. for page_idx in range(3, len(doc)):
  542. page = doc[page_idx]
  543. rects = page.search_for(clean_t)
  544. if rects:
  545. found_page = page_idx - 1
  546. break
  547. if found_page is None:
  548. found_page = 2
  549. global_page_map[raw_t] = found_page
  550. global_term_pages = {}
  551. for term in KEY_TERMS:
  552. matches = []
  553. for page_idx in range(2, len(doc)):
  554. page = doc[page_idx]
  555. rects = page.search_for(term)
  556. if rects:
  557. matches.append(page_idx - 1)
  558. if matches:
  559. global_term_pages[term] = matches
  560. doc_data[global_index_md_path]["page_map"] = global_page_map
  561. doc_data[global_index_md_path]["term_pages"] = global_term_pages
  562. doc.close()
  563. if os.path.exists(draft_global_pdf):
  564. os.remove(draft_global_pdf)
  565. except Exception as err:
  566. print(f"Draft analysis failed for Global_Index.md: {err}")
  567. # ==========================================
  568. # PASS 3: Generate Final PDFs
  569. # ==========================================
  570. print("\n=== COMPILING FINAL GENERATED PDF FILES ===")
  571. for md_file in md_files:
  572. pdf_file = md_file.replace('.md', '.pdf')
  573. base_name = os.path.basename(md_file)
  574. data = doc_data.get(md_file)
  575. if not data:
  576. continue
  577. title = data["title"]
  578. md_content = data["md_content"]
  579. headers = data["headers"]
  580. references = data["references"]
  581. page_map = data.get("page_map", {})
  582. term_pages = data.get("term_pages", {})
  583. # 1. Construct final printed TOC
  584. toc_lines = ["# Table of Contents\n\n"]
  585. for level, t in headers:
  586. indent = " " * (level - 1)
  587. p_num = page_map.get(t, 2)
  588. toc_lines.append(f"{indent}- {t} .................................................... Page {p_num}")
  589. toc_content = "\n".join(toc_lines)
  590. # 2. Construct final printed Index
  591. index_lines = ["## Index\n\n"]
  592. has_index_terms = False
  593. for term in KEY_TERMS:
  594. if term in term_pages:
  595. has_index_terms = True
  596. pages_str = ", ".join([f"Page {p}" for p in term_pages[term]])
  597. index_lines.append(f"- **{term}**: {pages_str}")
  598. if not has_index_terms:
  599. index_lines.append("- No key terms found in this document.")
  600. index_content = "\n".join(index_lines)
  601. try:
  602. pdf = compile_pdf_document(
  603. md_file, title, md_content, toc_content,
  604. index_content, references, root_dir, user_css
  605. )
  606. doc = pdf._make_doc()
  607. total_pages = len(doc)
  608. am_path = os.path.join(docs_dir, 'am.png')
  609. bts_path = os.path.join(docs_dir, 'Bts.png')
  610. width_am, height_am = 146.5, 24.5
  611. if os.path.exists(am_path):
  612. try:
  613. pix_am = fitz.Pixmap(am_path)
  614. width_am = pix_am.width * 0.1
  615. height_am = pix_am.height * 0.1
  616. except Exception: pass
  617. width_bts, height_bts = 67.1, 36.8
  618. if os.path.exists(bts_path):
  619. try:
  620. pix_bts = fitz.Pixmap(bts_path)
  621. width_bts = pix_bts.width * 0.1
  622. height_bts = pix_bts.height * 0.1
  623. except Exception: pass
  624. for page_idx in range(total_pages):
  625. # COVER & BLANK page should not contain headers, footers, or logos
  626. if page_idx in [0, 1]:
  627. continue
  628. page = doc[page_idx]
  629. width = page.rect.width
  630. height = page.rect.height
  631. # Header layout
  632. if os.path.exists(am_path):
  633. image_rect = fitz.Rect(48, 12, 48 + width_am, 12 + height_am)
  634. page.insert_image(image_rect, filename=am_path, overlay=False)
  635. if os.path.exists(bts_path):
  636. bts_rect = fitz.Rect(width - 48 - width_bts, 8, width - 48, 8 + height_bts)
  637. page.insert_image(bts_rect, filename=bts_path, overlay=False)
  638. # Title text
  639. fontsize = 8
  640. fontname = "helv"
  641. m_width = fitz.get_text_length(title, fontname=fontname, fontsize=fontsize)
  642. space_start = 48 + width_am
  643. space_end = width - 48 - width_bts
  644. title_x = space_start + (space_end - space_start - m_width) / 2
  645. page.insert_text(
  646. fitz.Point(title_x, 26),
  647. title,
  648. fontname=fontname,
  649. fontsize=fontsize,
  650. color=(0.5, 0.5, 0.5)
  651. )
  652. # Footer layout (Page numbers start counting at TOC as Page 1)
  653. l_footer = "lanfr144"
  654. footer_text = f"Page {page_idx - 1} of {total_pages - 2}"
  655. f_width = fitz.get_text_length(footer_text, fontname=fontname, fontsize=fontsize)
  656. page.insert_text(
  657. fitz.Point(48, height - 22),
  658. l_footer,
  659. fontname=fontname,
  660. fontsize=fontsize,
  661. color=(0.5, 0.5, 0.5)
  662. )
  663. page.insert_text(
  664. fitz.Point((width - f_width) / 2, height - 22),
  665. footer_text,
  666. fontname=fontname,
  667. fontsize=fontsize,
  668. color=(0.5, 0.5, 0.5)
  669. )
  670. r_footer = "DOPRO1"
  671. r_footer_width = fitz.get_text_length(r_footer, fontname=fontname, fontsize=fontsize)
  672. page.insert_text(
  673. fitz.Point(width - 48 - r_footer_width, height - 22),
  674. r_footer,
  675. fontname=fontname,
  676. fontsize=fontsize,
  677. color=(0.5, 0.5, 0.5)
  678. )
  679. doc.save(pdf_file, clean=True, garbage=3, deflate=True)
  680. doc.close()
  681. pdf.temp_files.clean()
  682. print(f"Saved {os.path.basename(pdf_file)}")
  683. # Copy to workspace root if applicable
  684. if base_name == 'project_report.md':
  685. dest = os.path.join(root_dir, 'Project.pdf')
  686. if os.path.exists(dest): os.remove(dest)
  687. shutil.copy2(pdf_file, dest)
  688. print("Successfully updated root Project.pdf")
  689. elif base_name == 'retro_planning.md':
  690. dest = os.path.join(root_dir, 'Retro Planning.pdf')
  691. if os.path.exists(dest): os.remove(dest)
  692. shutil.copy2(pdf_file, dest)
  693. print("Successfully updated root Retro Planning.pdf")
  694. except Exception as e:
  695. print(f"WARNING: Could not save {os.path.basename(pdf_file)}. File might be locked or open in a viewer. Error: {e}")
  696. # Generate HTML catalog portal in the workspace root
  697. generate_html_portal(root_dir, md_files)
  698. if __name__ == "__main__":
  699. main()