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,3 @@
1
+ """document-sanity: Build LaTeX documents from Markdown source with variable substitution and versioned builds."""
2
+
3
+ __version__ = "0.2.2"
@@ -0,0 +1,7 @@
1
+ """`python -m document_sanity` — the same CLI as `document-sanity`."""
2
+
3
+ import sys
4
+
5
+ from .cli import main
6
+
7
+ sys.exit(main())
document_sanity/bib.py ADDED
@@ -0,0 +1,250 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ Minimal BibTeX parser — just enough to render references in HTML.
4
+
5
+ Handles:
6
+ @article{key, field = {value}, field = "value", field = value, ... }
7
+ with balanced-brace field values and basic LaTeX-in-value unescaping
8
+ (\\'{e}, \\~{n}, ``…'', `…', --, ---).
9
+
10
+ Only fields we need for rendering are extracted: author, title, journal,
11
+ booktitle, year, volume, number, pages, doi, url, publisher, editor, month.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import html as _html
17
+ import re
18
+ from dataclasses import dataclass, field
19
+ from pathlib import Path
20
+
21
+
22
+ @dataclass
23
+ class BibEntry:
24
+ key: str
25
+ type: str
26
+ fields: dict[str, str] = field(default_factory=dict)
27
+
28
+
29
+ _ENTRY_START_RE = re.compile(r'@(\w+)\s*\{\s*([^,\s}]+)\s*,', re.MULTILINE)
30
+
31
+
32
+ def _find_entry_end(text: str, start: int) -> int:
33
+ """Return the index of the `}` that closes the entry opened just before `start`."""
34
+ depth = 1
35
+ i = start
36
+ in_quote = False
37
+ while i < len(text):
38
+ c = text[i]
39
+ if c == '\\' and i + 1 < len(text):
40
+ i += 2
41
+ continue
42
+ if c == '"' and not in_quote:
43
+ in_quote = True
44
+ elif c == '"' and in_quote:
45
+ in_quote = False
46
+ elif not in_quote:
47
+ if c == '{':
48
+ depth += 1
49
+ elif c == '}':
50
+ depth -= 1
51
+ if depth == 0:
52
+ return i
53
+ i += 1
54
+ return -1
55
+
56
+
57
+ def _parse_fields(body: str) -> dict[str, str]:
58
+ """Parse the `field = value, field = value, ...` part of a bib entry."""
59
+ fields: dict[str, str] = {}
60
+ i = 0
61
+ n = len(body)
62
+ while i < n:
63
+ # Skip whitespace and leading commas
64
+ while i < n and body[i] in ' \t\n,':
65
+ i += 1
66
+ if i >= n:
67
+ break
68
+ # Read field name
69
+ m = re.match(r'([A-Za-z][A-Za-z0-9_-]*)\s*=\s*', body[i:])
70
+ if not m:
71
+ break
72
+ name = m.group(1).lower()
73
+ i += m.end()
74
+ if i >= n:
75
+ break
76
+ # Read value: {...} | "..." | bareword
77
+ if body[i] == '{':
78
+ depth = 1
79
+ j = i + 1
80
+ while j < n and depth > 0:
81
+ c = body[j]
82
+ if c == '\\' and j + 1 < n:
83
+ j += 2
84
+ continue
85
+ if c == '{':
86
+ depth += 1
87
+ elif c == '}':
88
+ depth -= 1
89
+ j += 1
90
+ value = body[i + 1: j - 1]
91
+ i = j
92
+ elif body[i] == '"':
93
+ j = i + 1
94
+ while j < n and body[j] != '"':
95
+ if body[j] == '\\' and j + 1 < n:
96
+ j += 2
97
+ continue
98
+ j += 1
99
+ value = body[i + 1: j]
100
+ i = j + 1
101
+ else:
102
+ m2 = re.match(r'[^,\s]+', body[i:])
103
+ if not m2:
104
+ break
105
+ value = m2.group(0)
106
+ i += m2.end()
107
+ fields[name] = value.strip()
108
+ # Skip trailing whitespace/comma before next field
109
+ while i < n and body[i] in ' \t\n':
110
+ i += 1
111
+ if i < n and body[i] == ',':
112
+ i += 1
113
+ return fields
114
+
115
+
116
+ def parse_bib(content: str) -> dict[str, BibEntry]:
117
+ """Parse a .bib file and return {key: BibEntry}."""
118
+ entries: dict[str, BibEntry] = {}
119
+ for m in _ENTRY_START_RE.finditer(content):
120
+ entry_type = m.group(1).lower()
121
+ if entry_type in ('comment', 'preamble', 'string'):
122
+ continue
123
+ key = m.group(2)
124
+ body_start = m.end()
125
+ body_end = _find_entry_end(content, body_start)
126
+ if body_end == -1:
127
+ continue
128
+ body = content[body_start:body_end]
129
+ entries[key] = BibEntry(
130
+ key=key, type=entry_type,
131
+ fields=_parse_fields(body),
132
+ )
133
+ return entries
134
+
135
+
136
+ _INLINE_TEX_UNESCAPES = [
137
+ ('\\&', '&'), ('\\%', '%'), ('\\_', '_'), ('\\#', '#'), ('\\$', '$'),
138
+ ('\\{', '{'), ('\\}', '}'),
139
+ ("``", '\u201c'), ("''", '\u201d'), ('`', '\u2018'), ("'", '\u2019'),
140
+ ('---', '\u2014'), ('--', '\u2013'), ('~', ' '),
141
+ ]
142
+
143
+
144
+ def _clean_tex(s: str) -> str:
145
+ """Strip LaTeX markup we can't render and normalize whitespace."""
146
+ # Accents: \'{e} -> é, \"{o} -> ö, etc. (simplified; covers the common ones)
147
+ accents = {
148
+ "'": '\u0301', # acute
149
+ '`': '\u0300', # grave
150
+ '"': '\u0308', # umlaut
151
+ '^': '\u0302', # circumflex
152
+ '~': '\u0303', # tilde
153
+ '.': '\u0307', # dot above
154
+ 'c': '\u0327', # cedilla
155
+ }
156
+
157
+ def _accent(m: re.Match) -> str:
158
+ marker, ch = m.group(1), m.group(2)
159
+ import unicodedata
160
+ if marker in accents and ch:
161
+ return unicodedata.normalize('NFC', ch + accents[marker])
162
+ return ch
163
+
164
+ s = re.sub(r"\\([`'\"\^~\.c])\{?(\w?)\}?", _accent, s)
165
+ # \textit{x} etc. → x
166
+ s = re.sub(r'\\text(?:bf|it|rm|sf|tt|sc)\{([^{}]*)\}', r'\1', s)
167
+ s = re.sub(r'\\emph\{([^{}]*)\}', r'\1', s)
168
+ # Strip remaining braces
169
+ s = re.sub(r'[{}]', '', s)
170
+ # Common TeX-isms
171
+ for a, b in _INLINE_TEX_UNESCAPES:
172
+ s = s.replace(a, b)
173
+ s = re.sub(r'\s+', ' ', s).strip()
174
+ return s
175
+
176
+
177
+ def _format_authors(raw: str) -> str:
178
+ """`Smith, J. and Doe, J. and Other, A.` → `J. Smith, J. Doe, A. Other`."""
179
+ authors = [a.strip() for a in re.split(r'\s+and\s+', raw)]
180
+ out = []
181
+ for a in authors:
182
+ a = _clean_tex(a)
183
+ if ',' in a:
184
+ last, first = [x.strip() for x in a.split(',', 1)]
185
+ out.append(f'{first} {last}' if first else last)
186
+ else:
187
+ out.append(a)
188
+ if len(out) > 8:
189
+ out = out[:8] + ['et al.']
190
+ return ', '.join(out)
191
+
192
+
193
+ def render_entry_html(entry: BibEntry, number: int) -> str:
194
+ """Render one BibEntry as an HTML list item with exactly two children:
195
+ the `[N]` number span and a single `.bib-body` span holding the prose.
196
+ Keeping two children is required by our grid layout."""
197
+ f = entry.fields
198
+ segments: list[str] = []
199
+ if 'author' in f:
200
+ segments.append(
201
+ f'<span class="bib-author">{_html.escape(_format_authors(f["author"]))}</span>.'
202
+ )
203
+ if 'title' in f:
204
+ title = _clean_tex(f['title'])
205
+ segments.append(
206
+ f'<span class="bib-title">{_html.escape(title)}</span>.'
207
+ )
208
+ venue = f.get('journal') or f.get('booktitle') or f.get('publisher')
209
+ if venue:
210
+ # Combine venue + volume/number/pages/year on a single line
211
+ venue_parts = [f'<em class="bib-venue">{_html.escape(_clean_tex(venue))}</em>']
212
+ if 'volume' in f:
213
+ venue_parts.append(f' <strong>{_html.escape(f["volume"])}</strong>')
214
+ if 'number' in f:
215
+ venue_parts.append(f'({_html.escape(f["number"])})')
216
+ if 'pages' in f:
217
+ pages = f['pages'].replace('--', '\u2013')
218
+ venue_parts.append(f', {_html.escape(pages)}')
219
+ if 'year' in f:
220
+ venue_parts.append(f' ({_html.escape(f["year"])})')
221
+ venue_parts.append('.')
222
+ segments.append(''.join(venue_parts))
223
+ elif 'year' in f:
224
+ segments.append(f'({_html.escape(f["year"])}).')
225
+ link = None
226
+ if 'doi' in f:
227
+ doi = f['doi']
228
+ link = f'https://doi.org/{doi}' if not doi.startswith('http') else doi
229
+ elif 'url' in f:
230
+ link = f['url']
231
+ if link:
232
+ segments.append(
233
+ f'<a href="{_html.escape(link)}" target="_blank" rel="noopener">'
234
+ f'{_html.escape(link)}</a>'
235
+ )
236
+ body = ' '.join(segments)
237
+ return (f'<li id="ref-{_html.escape(entry.key)}" value="{number}">'
238
+ f'<span class="bib-number">[{number}]</span>'
239
+ f'<span class="bib-body">{body}</span></li>')
240
+
241
+
242
+ def load_bib(root_dir: Path, version: str) -> dict[str, BibEntry]:
243
+ """Locate and parse references.bib from the project (version-level then root)."""
244
+ for candidate in [
245
+ root_dir / 'src' / version / 'references.bib',
246
+ root_dir / 'references.bib',
247
+ ]:
248
+ if candidate.exists():
249
+ return parse_bib(candidate.read_text(encoding='utf-8'))
250
+ return {}