document-sanity 0.2.2__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (86) hide show
  1. document_sanity/__init__.py +3 -0
  2. document_sanity/__main__.py +7 -0
  3. document_sanity/bib.py +250 -0
  4. document_sanity/build.py +637 -0
  5. document_sanity/check.py +308 -0
  6. document_sanity/cli.py +1021 -0
  7. document_sanity/docx_builder.py +422 -0
  8. document_sanity/docx_content.py +406 -0
  9. document_sanity/docx_extract.py +321 -0
  10. document_sanity/docx_layout.py +257 -0
  11. document_sanity/docx_math.py +135 -0
  12. document_sanity/docx_styles.py +164 -0
  13. document_sanity/docx_template.py +293 -0
  14. document_sanity/docx_xml.py +381 -0
  15. document_sanity/drawio_fig.py +473 -0
  16. document_sanity/figure_crop.py +121 -0
  17. document_sanity/figure_manifest.py +124 -0
  18. document_sanity/html_builder.py +1265 -0
  19. document_sanity/init_project.py +644 -0
  20. document_sanity/labels.py +55 -0
  21. document_sanity/latex2md.py +554 -0
  22. document_sanity/make_version.py +85 -0
  23. document_sanity/manifest.py +630 -0
  24. document_sanity/md2docx.py +765 -0
  25. document_sanity/md2html.py +659 -0
  26. document_sanity/md2latex.py +556 -0
  27. document_sanity/pages.py +266 -0
  28. document_sanity/plotly_html.py +352 -0
  29. document_sanity/preview.py +739 -0
  30. document_sanity/project.py +61 -0
  31. document_sanity/site.py +279 -0
  32. document_sanity/skills/document-sanity/SKILL.md +104 -0
  33. document_sanity/skills/document-sanity/modes/_shared.md +94 -0
  34. document_sanity/skills/document-sanity/modes/check.md +48 -0
  35. document_sanity/skills/document-sanity/modes/cite.md +66 -0
  36. document_sanity/skills/document-sanity/modes/deploy.md +76 -0
  37. document_sanity/skills/document-sanity/modes/figures.md +97 -0
  38. document_sanity/skills/document-sanity/modes/import.md +61 -0
  39. document_sanity/skills/document-sanity/modes/init.md +79 -0
  40. document_sanity/skills/document-sanity/modes/iterate.md +74 -0
  41. document_sanity/skills/document-sanity/modes/onboard.md +127 -0
  42. document_sanity/skills/document-sanity/modes/variables.md +82 -0
  43. document_sanity/skills/document-sanity/modes/version.md +48 -0
  44. document_sanity/skills/document-sanity/modes/word.md +94 -0
  45. document_sanity/template/.gitignore +36 -0
  46. document_sanity/template/AGENTS.md +45 -0
  47. document_sanity/template/Makefile +72 -0
  48. document_sanity/template/README.md +234 -0
  49. document_sanity/template/pyproject.toml +31 -0
  50. document_sanity/template/src/manuscript/docs/discussion.md +19 -0
  51. document_sanity/template/src/manuscript/docs/introduction.md +33 -0
  52. document_sanity/template/src/manuscript/docs/methods.md +95 -0
  53. document_sanity/template/src/manuscript/docs/results.md +42 -0
  54. document_sanity/template/src/manuscript/figures/example/example.png +0 -0
  55. document_sanity/template/src/manuscript/figures/example/example.svg +16 -0
  56. document_sanity/template/src/manuscript/manifest.yaml +112 -0
  57. document_sanity/template/src/manuscript/references.bib +20 -0
  58. document_sanity/template/src/manuscript/tables/subgroup_summary.csv +6 -0
  59. document_sanity/template/src/proposal/docs/0_cover.md +19 -0
  60. document_sanity/template/src/proposal/docs/1_introduction.md +25 -0
  61. document_sanity/template/src/proposal/docs/2_aims.md +28 -0
  62. document_sanity/template/src/proposal/docs/3_approach.md +74 -0
  63. document_sanity/template/src/proposal/docs/4_plan.md +35 -0
  64. document_sanity/template/src/proposal/docs/5_impact.md +30 -0
  65. document_sanity/template/src/proposal/figures/architecture/architecture.png +0 -0
  66. document_sanity/template/src/proposal/manifest.yaml +99 -0
  67. document_sanity/template/src/proposal/references.bib +28 -0
  68. document_sanity/template/src/proposal/tables/milestones.csv +7 -0
  69. document_sanity/template/src/proposal/tables/risk_register.tsv +6 -0
  70. document_sanity/template/styles/article.css +30 -0
  71. document_sanity/template/styles/article.yaml +99 -0
  72. document_sanity/template/templates/article.docx +0 -0
  73. document_sanity/template/templates/article.tex +57 -0
  74. document_sanity/template/templates/logo.png +0 -0
  75. document_sanity/template/templates/proposal.tex +62 -0
  76. document_sanity/template/templates/watermark.png +0 -0
  77. document_sanity/template/uv.lock +348 -0
  78. document_sanity/template-source.json +4 -0
  79. document_sanity/variable_processor.py +374 -0
  80. document_sanity/verbatim.py +61 -0
  81. document_sanity/version.py +176 -0
  82. document_sanity-0.2.2.dist-info/METADATA +394 -0
  83. document_sanity-0.2.2.dist-info/RECORD +86 -0
  84. document_sanity-0.2.2.dist-info/WHEEL +4 -0
  85. document_sanity-0.2.2.dist-info/entry_points.txt +2 -0
  86. document_sanity-0.2.2.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,637 @@
1
+ #!/usr/bin/env python3
2
+ r"""
3
+ Build pipeline for document-sanity v2.
4
+
5
+ Pipeline:
6
+ src/<version>/docs/*.md --> out/<version>/latex/sections/*.tex
7
+ src/<version>/manifest.yaml --> out/<version>/latex/main.tex
8
+ out/<version>/latex/ --> out/<version>/pdf/main.pdf
9
+
10
+ Supports both the new src/ structure and legacy pigean-style structures.
11
+ """
12
+
13
+ import re
14
+ import shutil
15
+ import subprocess
16
+ import sys
17
+ from datetime import datetime
18
+ from pathlib import Path
19
+ from typing import Optional
20
+
21
+ from .manifest import Manifest
22
+ from .variable_processor import VariableProcessor
23
+ from .md2latex import convert_md_file, md_to_latex
24
+
25
+
26
+ # The LaTeX template a build falls back to when templates/<name>.tex is missing.
27
+ ARTICLE_TEMPLATE = """\
28
+ % document-sanity article template
29
+ % This template is assembled automatically by the build system.
30
+ % Insertion points are marked with %%DOCUMENT_SANITY: comments.
31
+
32
+ \\documentclass[12pt, a4paper]{article}
33
+
34
+ % Standard packages
35
+ \\usepackage[margin=1in]{geometry}
36
+ \\usepackage{graphicx}
37
+ \\usepackage{amsmath,amssymb}
38
+ \\usepackage{hyperref}
39
+ \\usepackage{booktabs}
40
+ \\usepackage{longtable}
41
+ \\usepackage{listings}
42
+ \\usepackage{xcolor}
43
+ \\usepackage{float}
44
+ \\usepackage{subcaption}
45
+ \\usepackage{natbib}
46
+
47
+ %%DOCUMENT_SANITY:PACKAGES
48
+
49
+ \\begin{document}
50
+
51
+ %%DOCUMENT_SANITY:TITLE
52
+
53
+ %%DOCUMENT_SANITY:ABSTRACT
54
+
55
+ %%DOCUMENT_SANITY:CONTENT
56
+
57
+ %%DOCUMENT_SANITY:BIBLIOGRAPHY
58
+
59
+ \\end{document}
60
+ """
61
+
62
+
63
+ class ManuscriptBuilder:
64
+ """Build a manuscript from src/ to out/."""
65
+
66
+ def __init__(
67
+ self,
68
+ root_dir: Path,
69
+ version: str,
70
+ placeholder: str = "XXXX",
71
+ strict: bool = False,
72
+ verbose: bool = False,
73
+ compile_pdf: bool = False,
74
+ compiler: str = "pdflatex",
75
+ ):
76
+ self.root_dir = root_dir
77
+ self.version = version
78
+ self.placeholder = placeholder
79
+ self.strict = strict
80
+ self.verbose = verbose
81
+ self.compile_pdf = compile_pdf
82
+ self.compiler = compiler
83
+
84
+ # Source paths
85
+ self.src_dir = root_dir / "src" / version
86
+ self.manifest_path = self.src_dir / "manifest.yaml"
87
+ self.templates_dir = root_dir / "templates"
88
+
89
+ # Output paths
90
+ self.out_dir = root_dir / "out" / version
91
+ self.out_latex_dir = self.out_dir / "latex"
92
+ self.out_sections_dir = self.out_latex_dir / "sections"
93
+ self.out_pdf_dir = self.out_dir / "pdf"
94
+ self.out_main_file = self.out_latex_dir / "main.tex"
95
+
96
+ # Load manifest
97
+ self.manifest = Manifest(self.manifest_path)
98
+
99
+ # Variable processor (with figure support from manifest)
100
+ self.processor = VariableProcessor(
101
+ placeholder=placeholder,
102
+ figures={fid: entry for fid, entry in self.manifest.figures.items()},
103
+ figures_dir=self.src_dir / "figures",
104
+ target="pdf",
105
+ default_variations=self.manifest.default_variations,
106
+ )
107
+
108
+ def _resolve_template(self) -> str:
109
+ """Load and resolve the LaTeX template."""
110
+ template_name = self.manifest.get_template_name()
111
+ template_path = self.templates_dir / f"{template_name}.tex"
112
+
113
+ if not template_path.exists():
114
+ # Fall back to the built-in article template
115
+ return ARTICLE_TEMPLATE
116
+
117
+ return template_path.read_text(encoding='utf-8')
118
+
119
+ def _build_title_block(self, template_text: str = "") -> str:
120
+ """Generate LaTeX title/author/affiliation block from manifest metadata.
121
+
122
+ If the template already contains \\maketitle, we don't add it again.
123
+ """
124
+ meta = self.manifest.metadata
125
+ template_name = self.manifest.get_template_name()
126
+ lines = []
127
+
128
+ if meta.title:
129
+ lines.append(f'\\title{{{meta.title}}}')
130
+
131
+ if meta.authors:
132
+ author_parts = []
133
+ for a in meta.authors:
134
+ name = a.name
135
+ if a.affiliations:
136
+ affil_str = ','.join(str(x) for x in a.affiliations)
137
+ name = name + '\\textsuperscript{' + affil_str + '}'
138
+ author_parts.append(name)
139
+ and_sep = ' \\and '
140
+ lines.append('\\author{' + and_sep.join(author_parts) + '}')
141
+
142
+ if meta.affiliations:
143
+ affil_lines = []
144
+ for num, info in sorted(meta.affiliations.items()):
145
+ if isinstance(info, dict):
146
+ parts = []
147
+ if 'department' in info:
148
+ parts.append(info['department'])
149
+ if 'institution' in info:
150
+ parts.append(info['institution'])
151
+ affil_str = ', '.join(parts)
152
+ else:
153
+ affil_str = str(info)
154
+ affil_lines.append('\\textsuperscript{' + str(num) + '}' + affil_str)
155
+ if affil_lines:
156
+ sep = ' \\\\ '
157
+ lines.append('\\date{' + sep.join(affil_lines) + '}')
158
+
159
+ # Add \maketitle if missing from template, except for nature template
160
+ # which needs it after the abstract.
161
+ if template_name != 'nature' and '\\maketitle' not in template_text:
162
+ lines.append('\\maketitle')
163
+
164
+ return '\n'.join(lines)
165
+
166
+ def _build_notice_block(self) -> str:
167
+ """LaTeX for manifest.metadata.notice: centered, small and italic,
168
+ ahead of the first section. Markdown, converted like a section."""
169
+ notice = self.manifest.metadata.notice
170
+ if not notice:
171
+ return ''
172
+ from .md2latex import md_to_latex
173
+ _, body = md_to_latex(notice.strip(), escape_text=False)
174
+ body = self.processor.replace_variables(body.strip(), 'manifest.yaml:notice')
175
+ return '\\begin{center}\n\\small\\itshape\n' + body + '\n\\end{center}'
176
+
177
+ def _build_abstract_block(self, template_text: str = "") -> str:
178
+ """Generate LaTeX abstract block.
179
+
180
+ Uses \\abstract{} for sn-jnl class (nature template),
181
+ \\begin{abstract} for standard article class.
182
+ """
183
+ if not self.manifest.metadata.abstract:
184
+ return ''
185
+
186
+ abstract = self.manifest.metadata.abstract.strip()
187
+ # A file-sourced abstract (abstract: docs/abstract.md) is markdown, so
188
+ # convert it to LaTeX the same way sections are (escape_text=False for
189
+ # LaTeX pass-through) before variable substitution. An inline string is
190
+ # left as raw LaTeX for back-compat.
191
+ if self.manifest.metadata.abstract_source:
192
+ from .md2latex import md_to_latex
193
+ _, abstract = md_to_latex(abstract, escape_text=False)
194
+ # Process variables in abstract
195
+ abstract = self.processor.replace_variables(abstract, 'manifest.yaml:abstract')
196
+
197
+ template_name = self.manifest.get_template_name()
198
+ lines = []
199
+
200
+ if template_name == 'nature':
201
+ # sn-jnl uses \abstract{...} syntax
202
+ lines.append('\\abstract{')
203
+ lines.append(abstract)
204
+ lines.append('}')
205
+ if self.manifest.metadata.keywords:
206
+ kw = ', '.join(self.manifest.metadata.keywords)
207
+ lines.append(f'\\keywords{{{kw}}}')
208
+
209
+ # sn-jnl needs \maketitle after abstract/keywords
210
+ if '\\maketitle' not in template_text:
211
+ lines.append('\\maketitle')
212
+ else:
213
+ lines.append('\\begin{abstract}')
214
+ lines.append(abstract)
215
+ lines.append('\\end{abstract}')
216
+ if self.manifest.metadata.keywords:
217
+ kw = ', '.join(self.manifest.metadata.keywords)
218
+ lines.append(f'\\textbf{{Keywords:}} {kw}')
219
+
220
+ return '\n'.join(lines)
221
+
222
+ def load_variables(self) -> None:
223
+ """Load variables from the manifest."""
224
+ print(f"\n Loading variables...")
225
+ values = self.manifest.get_variable_values()
226
+ self.processor.variables.update(values)
227
+ self.processor.load_external_data(self.src_dir, self.manifest.external_data)
228
+ print(f" Loaded {len(values)} variables from manifest")
229
+
230
+ n_prov = sum(1 for v in self.manifest.variables.values() if v.provenance)
231
+ if n_prov > 0:
232
+ print(f" {n_prov} variables have provenance records")
233
+
234
+ def convert_sections(self) -> list[str]:
235
+ """Convert markdown sections to LaTeX and process variables.
236
+
237
+ Returns list of output .tex paths relative to out_latex_dir.
238
+ """
239
+ print(f"\n Converting sections...")
240
+ self.out_sections_dir.mkdir(parents=True, exist_ok=True)
241
+
242
+ section_tex_paths = []
243
+ pseudo_sections = {'_bibliography', '_toc'}
244
+ for section_ref in self.manifest.sections:
245
+ # Pass through pseudo-sections (handled in assemble_main)
246
+ if section_ref in pseudo_sections:
247
+ section_tex_paths.append(section_ref)
248
+ continue
249
+
250
+ src_path = self.src_dir / section_ref
251
+
252
+ if not src_path.exists():
253
+ print(f" WARNING: Section not found: {section_ref}")
254
+ continue
255
+
256
+ # Determine output .tex name
257
+ stem = src_path.stem
258
+ out_name = f"{stem}.tex"
259
+ out_path = self.out_sections_dir / out_name
260
+ rel_path = f"sections/{out_name}"
261
+
262
+ if src_path.suffix == '.md':
263
+ if self.verbose:
264
+ print(f" [md->tex] {section_ref} -> {rel_path}")
265
+
266
+ # Expand {{tab:id}} tokens in the raw markdown so the
267
+ # table appears as a native markdown pipe table for
268
+ # md2latex to convert. Has to happen on .md, not on .tex.
269
+ from .manifest import expand_table_tokens
270
+ md_text = src_path.read_text(encoding='utf-8')
271
+ md_text = expand_table_tokens(md_text, self.manifest.tables, self.src_dir)
272
+
273
+ # Convert md -> tex (with escape_text=False for LaTeX pass-through)
274
+ from .md2latex import md_to_latex
275
+ _, latex_body = md_to_latex(md_text, escape_text=False)
276
+ out_path.parent.mkdir(parents=True, exist_ok=True)
277
+ out_path.write_text(latex_body, encoding='utf-8')
278
+
279
+ # Then process variables
280
+ content = out_path.read_text(encoding='utf-8')
281
+ processed = self.processor.replace_variables(content, str(src_path))
282
+ out_path.write_text(processed, encoding='utf-8')
283
+
284
+ elif src_path.suffix == '.tex':
285
+ if self.verbose:
286
+ print(f" [tex] {section_ref} -> {rel_path}")
287
+ self.processor.process_file(src_path, out_path)
288
+
289
+ else:
290
+ print(f" WARNING: Unknown section type: {section_ref}")
291
+ continue
292
+
293
+ section_tex_paths.append(rel_path)
294
+
295
+ print(f" Converted {len(section_tex_paths)} sections")
296
+ return section_tex_paths
297
+
298
+ def assemble_main(self, section_paths: list[str]) -> None:
299
+ """Assemble the main.tex from template + metadata + sections.
300
+
301
+ Supports special pseudo-sections in the manifest:
302
+ _bibliography -> inserts \\bibliography{references} at that point
303
+ _toc -> inserts \\tableofcontents at that point
304
+ """
305
+ print(f"\n Assembling main.tex...")
306
+
307
+ template = self._resolve_template()
308
+ # Honor manifest.metadata.render: when False, leave the
309
+ # %%DOCUMENT_SANITY:TITLE / :ABSTRACT insertion points empty so
310
+ # the template's own front matter (or none) takes effect.
311
+ if self.manifest.metadata.render:
312
+ title_block = self._build_title_block(template)
313
+ abstract_block = self._build_abstract_block(template)
314
+ else:
315
+ title_block = ''
316
+ abstract_block = ''
317
+
318
+ # Resolve bibliography
319
+ bib_cmd = ''
320
+ template_name = self.manifest.get_template_name()
321
+
322
+ refs_path = self.src_dir / "references.bib"
323
+ if refs_path.exists():
324
+ shutil.copy2(refs_path, self.out_latex_dir / "references.bib")
325
+ bib_cmd = '\\bibliography{references}'
326
+
327
+ root_refs = self.root_dir / "references.bib"
328
+ if root_refs.exists() and not refs_path.exists():
329
+ shutil.copy2(root_refs, self.out_latex_dir / "references.bib")
330
+ bib_cmd = '\\bibliographystyle{plain}\n\\bibliography{references}'
331
+
332
+ # Build content block, handling pseudo-sections
333
+ content_lines = []
334
+ notice_block = self._build_notice_block()
335
+ if notice_block:
336
+ content_lines += [notice_block, '']
337
+ bib_placed = False
338
+ for sec_path in section_paths:
339
+ if sec_path == '_bibliography':
340
+ if bib_cmd:
341
+ content_lines.append(bib_cmd)
342
+ content_lines.append('')
343
+ bib_placed = True
344
+ elif sec_path == '_toc':
345
+ content_lines.append('\\tableofcontents')
346
+ content_lines.append('\\newpage')
347
+ content_lines.append('')
348
+ else:
349
+ content_lines.append(f'\\input{{{sec_path}}}')
350
+ content_lines.append('')
351
+
352
+ content_block = '\n'.join(content_lines)
353
+
354
+ # If bibliography wasn't placed inline, put it at the end (default)
355
+ bib_block = '' if bib_placed else bib_cmd
356
+
357
+ # Fill template. Accept both current %%DOCUMENT_SANITY:* markers and
358
+ # legacy %%LATEX_BUILDER:* markers — paper repos authored against the
359
+ # old tool name keep building without a forced edit.
360
+ main_tex = template
361
+ for markers, replacement in [
362
+ (('%%DOCUMENT_SANITY:PACKAGES', '%%LATEX_BUILDER:PACKAGES'), ''),
363
+ (('%%DOCUMENT_SANITY:TITLE', '%%LATEX_BUILDER:TITLE'), title_block),
364
+ (('%%DOCUMENT_SANITY:ABSTRACT', '%%LATEX_BUILDER:ABSTRACT'), abstract_block),
365
+ (('%%DOCUMENT_SANITY:CONTENT', '%%LATEX_BUILDER:CONTENT'), content_block),
366
+ (('%%DOCUMENT_SANITY:BIBLIOGRAPHY', '%%LATEX_BUILDER:BIBLIOGRAPHY'), bib_block),
367
+ ]:
368
+ for marker in markers:
369
+ main_tex = main_tex.replace(marker, replacement)
370
+
371
+ # Process any remaining variables in main.tex
372
+ main_tex = self.processor.replace_variables(main_tex, 'main.tex')
373
+
374
+ self.out_latex_dir.mkdir(parents=True, exist_ok=True)
375
+ self.out_main_file.write_text(main_tex, encoding='utf-8')
376
+ print(f" Written: {self.out_main_file}")
377
+
378
+ def copy_figures(self) -> int:
379
+ """Copy figures to output directory, auto-cropping raster whitespace."""
380
+ from .figure_crop import copy_with_crop, pillow_available
381
+
382
+ print(f"\n Copying figures...")
383
+ figures_src = self.src_dir / "figures"
384
+ figures_dest = self.out_latex_dir / "figures"
385
+
386
+ if not figures_src.exists():
387
+ print(f" No figures directory found")
388
+ return 0
389
+
390
+ figures_dest.mkdir(parents=True, exist_ok=True)
391
+
392
+ # Per-figure crop toggle: manifest entries can set `crop: false` to
393
+ # opt out. Lookup by basename since output is flat.
394
+ crop_by_stem: dict[str, bool] = {}
395
+ for fig_id, entry in self.manifest.figures.items():
396
+ for p in [entry.source] + list(entry.formats.values()):
397
+ if p:
398
+ crop_by_stem[Path(p).stem] = entry.crop
399
+
400
+ n_copied = n_cropped = 0
401
+ for ext in ['*.png', '*.jpg', '*.jpeg', '*.pdf', '*.eps', '*.svg']:
402
+ for fig in figures_src.rglob(ext):
403
+ # figures/<id>/assets/ holds draw.io source assets — inputs to
404
+ # the composed figure, not build artifacts.
405
+ if 'assets' in fig.relative_to(figures_src).parts[:-1]:
406
+ continue
407
+ dest = figures_dest / fig.name
408
+ crop = crop_by_stem.get(fig.stem, True)
409
+ mode, info = copy_with_crop(fig, dest, crop=crop)
410
+ if mode == 'cropped':
411
+ n_cropped += 1
412
+ n_copied += 1
413
+ if self.verbose and info:
414
+ print(f" [cropped] {fig.name}: {info}")
415
+ elif mode == 'copied':
416
+ n_copied += 1
417
+
418
+ msg = f" Copied {n_copied} figures"
419
+ if pillow_available():
420
+ msg += f" ({n_cropped} cropped)"
421
+ else:
422
+ msg += " (Pillow not installed — cropping skipped)"
423
+ print(msg)
424
+ return n_copied
425
+
426
+ def copy_tables(self) -> int:
427
+ """Copy table files to output directory."""
428
+ print(f"\n Copying tables...")
429
+ tables_src = self.src_dir / "tables"
430
+ tables_dest = self.out_latex_dir / "tables"
431
+
432
+ if not tables_src.exists():
433
+ print(f" No tables directory found")
434
+ return 0
435
+
436
+ tables_dest.mkdir(parents=True, exist_ok=True)
437
+ count = 0
438
+ for f in tables_src.iterdir():
439
+ if f.is_file():
440
+ shutil.copy2(f, tables_dest / f.name)
441
+ count += 1
442
+
443
+ print(f" Copied {count} table files")
444
+ return count
445
+
446
+ def copy_supporting_files(self) -> None:
447
+ """Copy .bst, .cls files from templates/ to output."""
448
+ print(f"\n Copying supporting files...")
449
+ count = 0
450
+
451
+ # From templates/
452
+ if self.templates_dir.exists():
453
+ for ext in ['.bst', '.cls', '.sty', '.eps']:
454
+ for f in self.templates_dir.glob(f'*{ext}'):
455
+ shutil.copy2(f, self.out_latex_dir / f.name)
456
+ if self.verbose:
457
+ print(f" {f.name}")
458
+ count += 1
459
+
460
+ # From project root
461
+ for ext in ['.bst', '.cls', '.sty', '.eps']:
462
+ for f in self.root_dir.glob(f'*{ext}'):
463
+ dest = self.out_latex_dir / f.name
464
+ if not dest.exists():
465
+ shutil.copy2(f, dest)
466
+ count += 1
467
+
468
+ print(f" Copied {count} supporting files")
469
+
470
+ def compile_to_pdf(self) -> bool:
471
+ """Compile the assembled LaTeX to PDF.
472
+
473
+ Runs the compiler in the latex dir so that pdflatex, bibtex, and the
474
+ bib/bst/cls files all live in the same working directory. This avoids
475
+ bibtex's openout_any=p restriction on writing to absolute paths.
476
+ The final PDF is moved into the pdf/ dir.
477
+ """
478
+ print(f"\n Compiling to PDF...")
479
+
480
+ # Check if compiler exists
481
+ if not shutil.which(self.compiler):
482
+ print(f"\n Error: LaTeX compiler '{self.compiler}' not found.")
483
+ print(f" To build PDFs, you need a LaTeX distribution installed.")
484
+ print(f" Installation steps:")
485
+ print(f" - macOS: brew install --cask mactex-no-gui")
486
+ print(f" - Ubuntu/Debian: sudo apt-get install texlive-latex-extra texlive-fonts-recommended")
487
+ print(f" - Windows: Install MiKTeX (https://miktex.org/) or TeX Live")
488
+ return False
489
+
490
+ self.out_pdf_dir.mkdir(parents=True, exist_ok=True)
491
+
492
+ main_name = "main"
493
+ main_tex = f"{main_name}.tex"
494
+
495
+ if self.compiler == "latexmk":
496
+ cmd = ['latexmk', '-pdf', '-interaction=nonstopmode', main_tex]
497
+ subprocess.run(cmd, capture_output=True, text=True,
498
+ cwd=self.out_latex_dir)
499
+ else:
500
+ cmd_base = [self.compiler, '-interaction=nonstopmode']
501
+
502
+ # Pass 1: produces .aux with \citation entries
503
+ print(f" Pass 1/3: {self.compiler}...")
504
+ subprocess.run(cmd_base + [main_tex], capture_output=True,
505
+ text=True, cwd=self.out_latex_dir)
506
+
507
+ # BibTeX: reads .aux, produces .bbl
508
+ bib_files = list(self.out_latex_dir.glob("*.bib"))
509
+ if bib_files:
510
+ print(f" BibTeX...")
511
+ result = subprocess.run(['bibtex', main_name],
512
+ capture_output=True, text=True,
513
+ cwd=self.out_latex_dir)
514
+ if result.returncode != 0 and self.verbose:
515
+ print(f" BibTeX stderr: {result.stderr}")
516
+ print(f" BibTeX stdout: {result.stdout[-500:]}")
517
+
518
+ for i in [2, 3]:
519
+ print(f" Pass {i}/3: {self.compiler}...")
520
+ subprocess.run(cmd_base + [main_tex], capture_output=True,
521
+ text=True, cwd=self.out_latex_dir)
522
+
523
+ # Move the generated PDF and log artifacts into out_pdf_dir
524
+ built_pdf = self.out_latex_dir / f"{main_name}.pdf"
525
+ if built_pdf.exists():
526
+ shutil.copy2(built_pdf, self.out_pdf_dir / f"{main_name}.pdf")
527
+ for ext in ['.log', '.aux', '.out', '.toc', '.bbl', '.blg']:
528
+ f = self.out_latex_dir / f"{main_name}{ext}"
529
+ if f.exists():
530
+ shutil.copy2(f, self.out_pdf_dir / f.name)
531
+ print(f" PDF generated: {self.out_pdf_dir / f'{main_name}.pdf'}")
532
+ return True
533
+ else:
534
+ print(f" PDF generation failed")
535
+ return False
536
+
537
+ def write_available_variables(self) -> None:
538
+ """Write a list of all available variables to available_variables.txt."""
539
+ vars_list = self.processor.get_all_available_variables()
540
+ out_path = self.out_dir / "available_variables.txt"
541
+ out_path.write_text("\n".join(vars_list), encoding='utf-8')
542
+ print(f" Available variables written: {out_path}")
543
+
544
+ def generate_build_log(self) -> None:
545
+ """Write a build metadata log."""
546
+ import json
547
+ log = {
548
+ 'version': self.version,
549
+ 'built_at': datetime.now().isoformat(),
550
+ 'template': self.manifest.get_template_name(),
551
+ 'sections': self.manifest.sections,
552
+ 'variables_count': len(self.manifest.variables),
553
+ 'variables_with_provenance': sum(
554
+ 1 for v in self.manifest.variables.values() if v.provenance
555
+ ),
556
+ 'figures_count': len(self.manifest.figures),
557
+ 'tables_count': len(self.manifest.tables),
558
+ }
559
+ log_path = self.out_dir / "build.json"
560
+ log_path.write_text(json.dumps(log, indent=2))
561
+
562
+ def build(self) -> bool:
563
+ """Run the complete build pipeline."""
564
+ print(f"\n{'='*60}")
565
+ print(f" Building: {self.version}")
566
+ print(f" Source: {self.src_dir}")
567
+ print(f" Output: {self.out_dir}")
568
+ print(f"{'='*60}")
569
+
570
+ if not self.src_dir.exists():
571
+ print(f"\n ERROR: Source directory not found: {self.src_dir}")
572
+ return False
573
+
574
+ if not self.manifest_path.exists():
575
+ print(f"\n ERROR: Manifest not found: {self.manifest_path}")
576
+ return False
577
+
578
+ # Clean output
579
+ if self.out_dir.exists():
580
+ shutil.rmtree(self.out_dir)
581
+ self.out_dir.mkdir(parents=True, exist_ok=True)
582
+ self.out_latex_dir.mkdir(parents=True, exist_ok=True)
583
+
584
+ # Pipeline
585
+ self.load_variables()
586
+ section_paths = self.convert_sections()
587
+ self.assemble_main(section_paths)
588
+ self.copy_figures()
589
+ self.copy_tables()
590
+ self.copy_supporting_files()
591
+ self.write_available_variables()
592
+ self.generate_build_log()
593
+
594
+ # Report
595
+ self.processor.print_report(verbose=self.verbose)
596
+
597
+ has_errors = False
598
+ if self.processor.has_undefined_variables():
599
+ if self.strict:
600
+ print(f"\n BUILD FAILED: undefined variables (strict mode)")
601
+ has_errors = True
602
+ else:
603
+ print(f"\n Build completed with warnings")
604
+
605
+ if not has_errors:
606
+ print(f"\n Build complete!")
607
+ print(f" LaTeX: {self.out_latex_dir}")
608
+ print(f" Main: {self.out_main_file}")
609
+
610
+ # Compile
611
+ if self.compile_pdf and not has_errors:
612
+ self.compile_to_pdf()
613
+
614
+ return not has_errors
615
+
616
+
617
+ def find_latest_version(root_dir: Path) -> str:
618
+ """Find the latest version in src/."""
619
+ src_dir = root_dir / "src"
620
+ if not src_dir.exists():
621
+ raise ValueError(f"No src/ directory found in {root_dir}")
622
+
623
+ versions = [d.name for d in src_dir.iterdir() if d.is_dir() and (d / "manifest.yaml").exists()]
624
+ if not versions:
625
+ raise ValueError(f"No versions found in {src_dir} (no manifest.yaml)")
626
+
627
+ # Try to sort by date (MMDDYYYY prefix), then alphabetically
628
+ from .version import parse_version_date
629
+ dated = [(v, parse_version_date(v)) for v in versions]
630
+ dated_with_time = [(v, d) for v, d in dated if d is not None]
631
+ undated = [v for v, d in dated if d is None]
632
+
633
+ if dated_with_time:
634
+ dated_with_time.sort(key=lambda x: x[1], reverse=True)
635
+ return dated_with_time[0][0]
636
+ else:
637
+ return sorted(undated)[-1]