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/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 {}
|