generate_pdfs.py 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301
  1. #ident "@(#)$Format:LocalFoodAI_lanfr144:generate_pdfs.py:%an:%ae:%ad:%cn:%ce:%cd:%H:%D:%N$"
  2. import os
  3. import glob
  4. import re
  5. from markdown_pdf import MarkdownPdf
  6. from markdown_pdf import Section
  7. def main():
  8. docs_dir = os.path.join(os.path.dirname(__file__), '..', 'docs')
  9. md_files = glob.glob(os.path.join(docs_dir, '*.md'))
  10. if not md_files:
  11. print("No markdown files found in docs/")
  12. return
  13. # Use relative paths for fonts to ensure portability of PDF generation
  14. root_dir = os.path.abspath(os.path.join(os.path.dirname(__file__), '..')).replace('\\', '/')
  15. user_css = """
  16. * {
  17. color: #1a1a1a !important;
  18. }
  19. body {
  20. font-family: 'Helvetica', 'Arial', sans-serif !important;
  21. color: #1a1a1a !important;
  22. background-color: #ffffff !important;
  23. }
  24. h1, h2, h3, h4, h5, h6, h1 *, h2 *, h3 *, h4 *, h5 *, h6 * {
  25. color: #000000 !important;
  26. margin-top: 18px !important;
  27. margin-bottom: 8px !important;
  28. }
  29. pre {
  30. background-color: #f8f9fa !important;
  31. border: 1px solid #e9ecef !important;
  32. padding: 2px !important;
  33. margin-top: 8px !important;
  34. margin-bottom: 16px !important;
  35. border-radius: 3px !important;
  36. font-family: 'Courier New', 'Courier', monospace !important;
  37. font-size: 10pt !important;
  38. white-space: pre-wrap !important;
  39. word-break: break-all !important;
  40. }
  41. pre code, pre * {
  42. font-family: 'Courier New', 'Courier', monospace !important;
  43. font-size: 10pt !important;
  44. color: #212529 !important;
  45. background-color: #f8f9fa !important;
  46. white-space: pre-wrap !important;
  47. word-break: break-all !important;
  48. }
  49. code {
  50. font-family: 'Courier New', 'Courier', monospace !important;
  51. font-size: 10pt !important;
  52. color: #b02a37 !important;
  53. background-color: #f8f9fa !important;
  54. padding: 2px 4px !important;
  55. border-radius: 3px !important;
  56. white-space: pre-wrap !important;
  57. word-break: break-all !important;
  58. }
  59. a, a * {
  60. color: #0d6efd !important;
  61. }
  62. blockquote, blockquote * {
  63. color: #555555 !important;
  64. border-left: 4px solid #ccc !important;
  65. padding-left: 10px !important;
  66. }
  67. table, tr, td, th, table * {
  68. color: #1a1a1a !important;
  69. border-color: #cccccc !important;
  70. }
  71. th {
  72. background-color: #f2f2f2 !important;
  73. }
  74. ul, li {
  75. list-style-type: disc !important;
  76. }
  77. """
  78. for md_file in md_files:
  79. pdf_file = md_file.replace('.md', '.pdf')
  80. print(f"Converting {os.path.basename(md_file)} to PDF...")
  81. with open(md_file, 'r', encoding='utf-8') as f:
  82. md_content = f.read()
  83. import subprocess
  84. def sanitize_name(name):
  85. if not name:
  86. return "Francois Lange"
  87. name_lower = name.lower()
  88. if "fran" in name_lower or "lange" in name_lower or "lanfr" in name_lower:
  89. return "Francois Lange"
  90. return name
  91. def get_git_info_for_file(file_path):
  92. try:
  93. cmd = [
  94. "git", "log", "-1",
  95. "--date=format:%Y/%m/%d %H:%M:%S",
  96. "--format=%an|%ae|%ad|%cn|%ce|%cd|%H|%D|%N",
  97. "--", file_path
  98. ]
  99. out = subprocess.check_output(cmd, stderr=subprocess.DEVNULL).decode('utf-8', errors='ignore').strip()
  100. if out:
  101. parts = out.split('|')
  102. if len(parts) == 9:
  103. parts[0] = sanitize_name(parts[0])
  104. parts[3] = sanitize_name(parts[3])
  105. return parts
  106. except Exception:
  107. pass
  108. author_name = "Francois Lange"
  109. try:
  110. author_email = subprocess.check_output(["git", "config", "user.email"], stderr=subprocess.DEVNULL).decode('utf-8', errors='ignore').strip() or "lanfr144@school.lu"
  111. except Exception:
  112. author_email = "lanfr144@school.lu"
  113. from datetime import datetime
  114. now_str = datetime.now().strftime("%Y/%m/%d %H:%M:%S")
  115. return [author_name, author_email, now_str, author_name, author_email, now_str, "Not Committed Yet", "local", "none"]
  116. # Dynamic smudging of the Format placeholder for this specific file
  117. base_name = os.path.basename(md_file)
  118. info = get_git_info_for_file(md_file)
  119. replacement = f"$Format:LocalFoodAI_lanfr144:generate_pdfs.py:%an:%ae:%ad:%cn:%ce:%cd:%H:%D:%N$"
  120. # Replace the raw format template if present
  121. pattern = r'\$Format:LocalFoodAI_lanfr144:generate_pdfs.py:%an:%ae:%ad:%cn:%ce:%cd:%H:%D:%N$'
  122. md_content = re.sub(pattern, replacement, md_content)
  123. # Clean up absolute file:/// paths to relative paths
  124. md_content = re.sub(r'file:///.*?/docs/([a-zA-Z0-9_-]+)\.md', r'\1.pdf', md_content, flags=re.IGNORECASE)
  125. md_content = re.sub(r'file:///.*?/Food/([a-zA-Z0-9_.-]+)', r'../\1', md_content, flags=re.IGNORECASE)
  126. # Add a blank line after code blocks to force copy-paste blank line
  127. md_content = re.sub(r'(```[a-zA-Z0-9_-]*\n[\s\S]*?\n```)', r'\1\n\n', md_content)
  128. # Convert any relative markdown links in standard format [text](filename.md) or [text](./filename.md) to point to .pdf
  129. 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)
  130. try:
  131. pdf = MarkdownPdf(toc_level=2, optimize=True, plugins={"mermaid": {}})
  132. if base_name == 'project_report.md':
  133. print("Splitting project_report.md into Portrait/Landscape/Portrait sections...")
  134. parts = md_content.split('## 2. Project File Catalog & Documentation')
  135. if len(parts) == 2:
  136. portrait_part1 = parts[0]
  137. remaining = parts[1]
  138. parts2 = remaining.split('## 3. Directory Structure Map')
  139. if len(parts2) == 2:
  140. landscape_part = '## 2. Project File Catalog & Documentation' + parts2[0]
  141. portrait_part2 = '## 3. Directory Structure Map' + parts2[1]
  142. pdf.add_section(Section(portrait_part1, paper_size="A4", root=root_dir), user_css=user_css)
  143. pdf.add_section(Section(landscape_part, paper_size="A4-L", root=root_dir), user_css=user_css)
  144. pdf.add_section(Section(portrait_part2, paper_size="A4", root=root_dir), user_css=user_css)
  145. else:
  146. print("WARNING: Could not find Directory Structure Map heading. Defaulting to full portrait.")
  147. pdf.add_section(Section(md_content, paper_size="A4", root=root_dir), user_css=user_css)
  148. else:
  149. print("WARNING: Could not find Project File Catalog heading. Defaulting to full portrait.")
  150. pdf.add_section(Section(md_content, paper_size="A4", root=root_dir), user_css=user_css)
  151. else:
  152. pdf.add_section(Section(md_content, paper_size="A4", root=root_dir), user_css=user_css)
  153. # Post-process compiled PDF to insert header and footer
  154. import fitz
  155. doc = pdf._make_doc()
  156. total_pages = len(doc)
  157. # Parse title from md_content
  158. title_match = re.search(r'^#\s+(.+)$', md_content, re.MULTILINE)
  159. if title_match:
  160. doc_title = title_match.group(1).strip()
  161. doc_title = re.sub(r'[*_`#]', '', doc_title).strip()
  162. else:
  163. doc_title = os.path.basename(pdf_file).replace('.pdf', '').replace('_', ' ')
  164. am_path = os.path.join(docs_dir, 'am.png')
  165. bts_path = os.path.join(docs_dir, 'Bts.png')
  166. # Read real sizes for scaling to 10%
  167. width_am, height_am = 146.5, 24.5 # Fallback defaults
  168. if os.path.exists(am_path):
  169. try:
  170. pix_am = fitz.Pixmap(am_path)
  171. width_am = pix_am.width * 0.1
  172. height_am = pix_am.height * 0.1
  173. except Exception:
  174. pass
  175. width_bts, height_bts = 67.1, 36.8 # Fallback defaults
  176. if os.path.exists(bts_path):
  177. try:
  178. pix_bts = fitz.Pixmap(bts_path)
  179. width_bts = pix_bts.width * 0.1
  180. height_bts = pix_bts.height * 0.1
  181. except Exception:
  182. pass
  183. for page_idx in range(total_pages):
  184. page = doc[page_idx]
  185. width = page.rect.width
  186. height = page.rect.height
  187. # Header layout
  188. # Left: am.png picture (if exists) at 10% size
  189. if os.path.exists(am_path):
  190. image_rect = fitz.Rect(48, 12, 48 + width_am, 12 + height_am)
  191. page.insert_image(image_rect, filename=am_path)
  192. # Right: Bts.png logo (if exists) at 10% size
  193. if os.path.exists(bts_path):
  194. bts_rect = fitz.Rect(width - 48 - width_bts, 8, width - 48, 8 + height_bts)
  195. page.insert_image(bts_rect, filename=bts_path)
  196. # Middle: document title (centered)
  197. fontsize = 8
  198. fontname = "helv"
  199. m_width = fitz.get_text_length(doc_title, fontname=fontname, fontsize=fontsize)
  200. page.insert_text(
  201. fitz.Point((width - m_width) / 2, 26),
  202. doc_title,
  203. fontname=fontname,
  204. fontsize=fontsize,
  205. color=(0.5, 0.5, 0.5)
  206. )
  207. # Footer layout
  208. # Left: "lanfr144"
  209. # Middle: "Page X of Y"
  210. # Right: "DOPRO1"
  211. l_footer = "lanfr144"
  212. footer_text = f"Page {page_idx + 1} of {total_pages}"
  213. f_width = fitz.get_text_length(footer_text, fontname=fontname, fontsize=fontsize)
  214. page.insert_text(
  215. fitz.Point(48, height - 22),
  216. l_footer,
  217. fontname=fontname,
  218. fontsize=fontsize,
  219. color=(0.5, 0.5, 0.5)
  220. )
  221. page.insert_text(
  222. fitz.Point((width - f_width) / 2, height - 22),
  223. footer_text,
  224. fontname=fontname,
  225. fontsize=fontsize,
  226. color=(0.5, 0.5, 0.5)
  227. )
  228. r_footer = "DOPRO1"
  229. r_footer_width = fitz.get_text_length(r_footer, fontname=fontname, fontsize=fontsize)
  230. page.insert_text(
  231. fitz.Point(width - 48 - r_footer_width, height - 22),
  232. r_footer,
  233. fontname=fontname,
  234. fontsize=fontsize,
  235. color=(0.5, 0.5, 0.5)
  236. )
  237. # Deflate, clean, garbage collect and linearize the document
  238. # to fix Acrobat encoding and save prompts
  239. doc.save(pdf_file, clean=True, garbage=3, deflate=True)
  240. doc.close()
  241. pdf.temp_files.clean()
  242. print(f"Saved {os.path.basename(pdf_file)}")
  243. # Copy to workspace root if applicable
  244. import shutil
  245. if base_name == 'project_report.md':
  246. dest = os.path.join(root_dir, 'Project.pdf')
  247. try:
  248. if os.path.exists(dest):
  249. os.remove(dest)
  250. shutil.copy2(pdf_file, dest)
  251. print("Successfully updated root Project.pdf")
  252. except Exception as copy_err:
  253. print(f"WARNING: Could not copy to root Project.pdf: {copy_err}")
  254. elif base_name == 'retro_planning.md':
  255. dest = os.path.join(root_dir, 'Retro Planning.pdf')
  256. try:
  257. if os.path.exists(dest):
  258. os.remove(dest)
  259. shutil.copy2(pdf_file, dest)
  260. print("Successfully updated root Retro Planning.pdf")
  261. except Exception as copy_err:
  262. print(f"WARNING: Could not copy to root Retro Planning.pdf: {copy_err}")
  263. except Exception as e:
  264. print(f"WARNING: Could not save {os.path.basename(pdf_file)}. File might be locked or open in a viewer. Error: {e}")
  265. if __name__ == "__main__":
  266. main()