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.
- document_sanity/__init__.py +3 -0
- document_sanity/__main__.py +7 -0
- document_sanity/bib.py +250 -0
- document_sanity/build.py +637 -0
- document_sanity/check.py +308 -0
- document_sanity/cli.py +1021 -0
- document_sanity/docx_builder.py +422 -0
- document_sanity/docx_content.py +406 -0
- document_sanity/docx_extract.py +321 -0
- document_sanity/docx_layout.py +257 -0
- document_sanity/docx_math.py +135 -0
- document_sanity/docx_styles.py +164 -0
- document_sanity/docx_template.py +293 -0
- document_sanity/docx_xml.py +381 -0
- document_sanity/drawio_fig.py +473 -0
- document_sanity/figure_crop.py +121 -0
- document_sanity/figure_manifest.py +124 -0
- document_sanity/html_builder.py +1265 -0
- document_sanity/init_project.py +644 -0
- document_sanity/labels.py +55 -0
- document_sanity/latex2md.py +554 -0
- document_sanity/make_version.py +85 -0
- document_sanity/manifest.py +630 -0
- document_sanity/md2docx.py +765 -0
- document_sanity/md2html.py +659 -0
- document_sanity/md2latex.py +556 -0
- document_sanity/pages.py +266 -0
- document_sanity/plotly_html.py +352 -0
- document_sanity/preview.py +739 -0
- document_sanity/project.py +61 -0
- document_sanity/site.py +279 -0
- document_sanity/skills/document-sanity/SKILL.md +104 -0
- document_sanity/skills/document-sanity/modes/_shared.md +94 -0
- document_sanity/skills/document-sanity/modes/check.md +48 -0
- document_sanity/skills/document-sanity/modes/cite.md +66 -0
- document_sanity/skills/document-sanity/modes/deploy.md +76 -0
- document_sanity/skills/document-sanity/modes/figures.md +97 -0
- document_sanity/skills/document-sanity/modes/import.md +61 -0
- document_sanity/skills/document-sanity/modes/init.md +79 -0
- document_sanity/skills/document-sanity/modes/iterate.md +74 -0
- document_sanity/skills/document-sanity/modes/onboard.md +127 -0
- document_sanity/skills/document-sanity/modes/variables.md +82 -0
- document_sanity/skills/document-sanity/modes/version.md +48 -0
- document_sanity/skills/document-sanity/modes/word.md +94 -0
- document_sanity/template/.gitignore +36 -0
- document_sanity/template/AGENTS.md +45 -0
- document_sanity/template/Makefile +72 -0
- document_sanity/template/README.md +234 -0
- document_sanity/template/pyproject.toml +31 -0
- document_sanity/template/src/manuscript/docs/discussion.md +19 -0
- document_sanity/template/src/manuscript/docs/introduction.md +33 -0
- document_sanity/template/src/manuscript/docs/methods.md +95 -0
- document_sanity/template/src/manuscript/docs/results.md +42 -0
- document_sanity/template/src/manuscript/figures/example/example.png +0 -0
- document_sanity/template/src/manuscript/figures/example/example.svg +16 -0
- document_sanity/template/src/manuscript/manifest.yaml +112 -0
- document_sanity/template/src/manuscript/references.bib +20 -0
- document_sanity/template/src/manuscript/tables/subgroup_summary.csv +6 -0
- document_sanity/template/src/proposal/docs/0_cover.md +19 -0
- document_sanity/template/src/proposal/docs/1_introduction.md +25 -0
- document_sanity/template/src/proposal/docs/2_aims.md +28 -0
- document_sanity/template/src/proposal/docs/3_approach.md +74 -0
- document_sanity/template/src/proposal/docs/4_plan.md +35 -0
- document_sanity/template/src/proposal/docs/5_impact.md +30 -0
- document_sanity/template/src/proposal/figures/architecture/architecture.png +0 -0
- document_sanity/template/src/proposal/manifest.yaml +99 -0
- document_sanity/template/src/proposal/references.bib +28 -0
- document_sanity/template/src/proposal/tables/milestones.csv +7 -0
- document_sanity/template/src/proposal/tables/risk_register.tsv +6 -0
- document_sanity/template/styles/article.css +30 -0
- document_sanity/template/styles/article.yaml +99 -0
- document_sanity/template/templates/article.docx +0 -0
- document_sanity/template/templates/article.tex +57 -0
- document_sanity/template/templates/logo.png +0 -0
- document_sanity/template/templates/proposal.tex +62 -0
- document_sanity/template/templates/watermark.png +0 -0
- document_sanity/template/uv.lock +348 -0
- document_sanity/template-source.json +4 -0
- document_sanity/variable_processor.py +374 -0
- document_sanity/verbatim.py +61 -0
- document_sanity/version.py +176 -0
- document_sanity-0.2.2.dist-info/METADATA +394 -0
- document_sanity-0.2.2.dist-info/RECORD +86 -0
- document_sanity-0.2.2.dist-info/WHEEL +4 -0
- document_sanity-0.2.2.dist-info/entry_points.txt +2 -0
- document_sanity-0.2.2.dist-info/licenses/LICENSE +21 -0
document_sanity/build.py
ADDED
|
@@ -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]
|