csl-pyutil 0.24.1__tar.gz

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 (33) hide show
  1. csl_pyutil-0.24.1/LICENSE +21 -0
  2. csl_pyutil-0.24.1/PKG-INFO +15 -0
  3. csl_pyutil-0.24.1/README.md +255 -0
  4. csl_pyutil-0.24.1/csl_pyutil/__init__.py +68 -0
  5. csl_pyutil-0.24.1/csl_pyutil/anatomy.py +237 -0
  6. csl_pyutil-0.24.1/csl_pyutil/evidence.py +512 -0
  7. csl_pyutil-0.24.1/csl_pyutil/integrity_tripwire.py +679 -0
  8. csl_pyutil-0.24.1/csl_pyutil/review_sheet.py +3458 -0
  9. csl_pyutil-0.24.1/csl_pyutil.egg-info/PKG-INFO +15 -0
  10. csl_pyutil-0.24.1/csl_pyutil.egg-info/SOURCES.txt +31 -0
  11. csl_pyutil-0.24.1/csl_pyutil.egg-info/dependency_links.txt +1 -0
  12. csl_pyutil-0.24.1/csl_pyutil.egg-info/requires.txt +3 -0
  13. csl_pyutil-0.24.1/csl_pyutil.egg-info/top_level.txt +1 -0
  14. csl_pyutil-0.24.1/pyproject.toml +28 -0
  15. csl_pyutil-0.24.1/setup.cfg +4 -0
  16. csl_pyutil-0.24.1/tests/test_anatomy.py +100 -0
  17. csl_pyutil-0.24.1/tests/test_evidence.py +286 -0
  18. csl_pyutil-0.24.1/tests/test_export_context.py +60 -0
  19. csl_pyutil-0.24.1/tests/test_fixture_byte_identical.py +30 -0
  20. csl_pyutil-0.24.1/tests/test_generator_meta.py +24 -0
  21. csl_pyutil-0.24.1/tests/test_handin.py +127 -0
  22. csl_pyutil-0.24.1/tests/test_identity_gate.py +81 -0
  23. csl_pyutil-0.24.1/tests/test_integrity_tripwire.py +647 -0
  24. csl_pyutil-0.24.1/tests/test_mobile_css.py +16 -0
  25. csl_pyutil-0.24.1/tests/test_review_sheet.py +639 -0
  26. csl_pyutil-0.24.1/tests/test_ru_ui_strings.py +42 -0
  27. csl_pyutil-0.24.1/tests/test_split_layout.py +98 -0
  28. csl_pyutil-0.24.1/tests/test_timing.py +64 -0
  29. csl_pyutil-0.24.1/tests/test_typology_stats.py +74 -0
  30. csl_pyutil-0.24.1/tests/test_v15_session_flow.py +405 -0
  31. csl_pyutil-0.24.1/tests/test_v16_packset.py +463 -0
  32. csl_pyutil-0.24.1/tests/test_v17_vote_ux.py +204 -0
  33. csl_pyutil-0.24.1/tests/test_version_strings.py +35 -0
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Sanskrit Lexicon / Cologne Digital Sanskrit Dictionaries contributors
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,15 @@
1
+ Metadata-Version: 2.4
2
+ Name: csl-pyutil
3
+ Version: 0.24.1
4
+ Summary: Generic (non-Sanskrit-specific) Python helpers shared across the CDSL / Sanskrit-Lexicon repos
5
+ Author: Sanskrit Lexicon / CDSL
6
+ License: MIT
7
+ Project-URL: Homepage, https://github.com/sanskrit-lexicon/csl-pyutil
8
+ Keywords: cdsl,lexicography,review-sheet,tooling
9
+ Classifier: Programming Language :: Python :: 3
10
+ Classifier: License :: OSI Approved :: MIT License
11
+ Requires-Python: >=3.8
12
+ License-File: LICENSE
13
+ Provides-Extra: test
14
+ Requires-Dist: pytest; extra == "test"
15
+ Dynamic: license-file
@@ -0,0 +1,255 @@
1
+ # csl-pyutil
2
+
3
+ _Created: 14-07-2026 · Last updated: 05-09-2026_
4
+
5
+ Generic (non-Sanskrit-specific) Python helpers shared across the CDSL /
6
+ Sanskrit-Lexicon repos. Distinct from
7
+ [`sanskrit-util`](https://github.com/sanskrit-lexicon/sanskrit-util), which is
8
+ scoped narrowly to Sanskrit string/transcoding helpers (IAST/SLP1/Devanāgarī)
9
+ — this package is for everything else that shouldn't be re-typed per repo but
10
+ also isn't Sanskrit-linguistics-specific.
11
+
12
+ ## Why this exists
13
+
14
+ A [cross-repo dev-status + reuse review](https://github.com/gasyoun/Uprava/blob/main/CROSS_REPO_DEV_STATUS_AND_REUSE_REVIEW_07.2026.md)
15
+ found **six independently hand-rolled ~150–250-line HTML review-sheet shells**
16
+ across four repos — all implementing the same approve/reject/defer pattern
17
+ (running tally, `localStorage` persistence, a/r/d keyboard shortcuts, free-text
18
+ notes, `Download decisions.json` with an identical
19
+ `{sheet_id, generated, decided, items}` schema) — with **no shared import
20
+ between any of them**. Root cause: the org's `/review-sheet` Claude Code skill
21
+ is prose-only, so every invocation re-derived the same markup/JS from
22
+ scratch. Proven drift: none of the six shells implemented the skill-mandated
23
+ `showSaveFilePicker` auto-save + legend footer (H779, 12-07-2026) — manual
24
+ propagation had already failed once.
25
+
26
+ ## `render_review_sheet()`
27
+
28
+ ```sh
29
+ pip install "csl-pyutil @ git+https://github.com/sanskrit-lexicon/csl-pyutil@main"
30
+ ```
31
+
32
+ ```python
33
+ from csl_pyutil import render_review_sheet
34
+
35
+ html = render_review_sheet(
36
+ items=[
37
+ {"id": "L142", "filt": "typeA", "title": "headword or item label",
38
+ "badges": ["tag1", "tag2"],
39
+ "question": "the judgment question shown on the card (HTML allowed)",
40
+ "panels": [("Panel heading", "<pre>panel body, HTML allowed</pre>")],
41
+ "note_placeholder": "optional custom placeholder text"},
42
+ ...
43
+ ],
44
+ config={
45
+ "sheet_id": "commentarystrategies-sundarakanda_35-37", # org naming convention
46
+ "title": "Sundarakāṇḍa commentary xref — sarga 35-37",
47
+ "subtitle": "43 candidate cross-references needing a decision",
48
+ "footer": "Approve = accept the xref. Reject = discard. Defer = unsure.",
49
+ "approve_label": "Approve", "reject_label": "Reject",
50
+ "filters": [("typeA", "Type A"), ("typeB", "Type B")],
51
+ "generated": "2026-07-14", # caller-supplied, never computed here — deterministic output
52
+ # Optional: make final exports publication-review admissible.
53
+ "strict_review": {
54
+ "reviewer": "gasyoun",
55
+ "require_all_votes": True,
56
+ "require_reject_note": True,
57
+ },
58
+ },
59
+ # Required when extras=True (default) since v0.8.0 / H1649:
60
+ screening={
61
+ "deterministic": 0,
62
+ "lookup": 0,
63
+ "agent": 0,
64
+ "human": 43,
65
+ "evidence_path": "review/screening_evidence.md",
66
+ "rules": ["none"], # or the real rule names applied
67
+ },
68
+ )
69
+ open("review/commentarystrategies-sundarakanda_35-37_review.html", "w",
70
+ encoding="utf-8").write(html)
71
+ ```
72
+
73
+ Ported byte-for-byte (proven by a fixture test, `tests/test_fixture_byte_identical.py`)
74
+ from
75
+ [`SanskritLexicography/RussianTranslation/src/build_h180_review_sheets.py`](https://github.com/gasyoun/SanskritLexicography/blob/master/RussianTranslation/src/build_h180_review_sheets.py)
76
+ — the richest of the six hand-rolled shells (H925). `extras=True` (the
77
+ default) additionally folds in what H779 mandated but no shell implemented: a
78
+ File System Access API "Save to folder" auto-save control and a button-legend
79
+ footer. Pass `extras=False` only to reproduce a pre-H779 shell's literal
80
+ historical output.
81
+
82
+ Exported decisions-JSON shape (unchanged from every shell's prior contract, so
83
+ [`Uprava/tools/review_decisions_watcher.py`](https://github.com/gasyoun/Uprava/blob/main/tools/review_decisions_watcher.py)
84
+ needs no changes):
85
+
86
+ ```json
87
+ {"sheet_id": "...", "generated": "2026-07-14T12:00:00.000Z", "decided": 12,
88
+ "items": [{"id": "L142", "decision": "approve", "note": ""}, ...]}
89
+ ```
90
+
91
+ With `strict_review`, the same payload gains additive top-level fields:
92
+
93
+ ```json
94
+ {"sheet_id": "...", "generated": "2026-07-14", "decided": 12,
95
+ "reviewer": "gasyoun", "reviewedAt": "2026-07-17T20:45:00.000Z",
96
+ "complete": true,
97
+ "items": [{"id": "L142", "decision": "approve", "note": ""}, ...]}
98
+ ```
99
+
100
+ Partial File System Access API auto-saves use `complete: false` and
101
+ `reviewedAt: null`. Final download is blocked until the reviewer is non-empty,
102
+ every item is voted, and every rejected item has a note. Callers that omit
103
+ `strict_review` retain the 0.1.x behavior and byte-identical core rendering.
104
+
105
+ See [`csl_pyutil/review_sheet.py`](https://github.com/sanskrit-lexicon/csl-pyutil/blob/main/csl_pyutil/review_sheet.py) for the full
106
+ item/config schema docstring.
107
+
108
+ ### Presentation (0.5.0)
109
+
110
+ `config["font_scale"]` multiplies the whole type scale and **defaults to 1.5** —
111
+ MG's "+150%", ruled 28-07-2026 while voting the G5 sheet, together with a fix to
112
+ the inherited hierarchy: the panel `<pre>` holding the text under judgement used
113
+ to be the smallest type on the page. An A−/A+ toolbar control re-points the scale
114
+ per browser. `font_scale=1` restores the donor sizes; `extras=False` never gets
115
+ the layer, so the byte-identical fixture stands.
116
+
117
+ `config["extra_css"]` appends caller CSS last in the cascade.
118
+
119
+ ### The evidence gate — V9 / V10 (0.9.0)
120
+
121
+ V1–V8 are entirely about *presentation*. A sheet can be green on every one of them
122
+ and still ask a human to re-derive, by eye, a conclusion the repo already holds on
123
+ disk — measured 29-07-2026 on the sheet that prompted this: **191 of 200 cards**
124
+ already carried a machine verdict, a named rule and cited evidence computed from
125
+ the same inputs, none of it rendered, and **69 of 200** were not disagreements at
126
+ all. So the emitter now refuses to write those sheets.
127
+
128
+ ```python
129
+ from csl_pyutil import EvidenceManifest, render_review_sheet
130
+
131
+ man = EvidenceManifest(sheet_id=SHEET_ID, row_ids=[i["id"] for i in items],
132
+ repo_root=REPO)
133
+ man.declare_joined("research/adjudication.tsv", ["verdict", "rule", "reason"])
134
+ man.declare_omitted_path("research/superseded_run.tsv",
135
+ because="superseded by the H1681 rerun, kept for audit")
136
+ man.declare_omitted("DCS attested sentence",
137
+ because="no per-compound sentence map exists; only sense-level")
138
+ for it in items:
139
+ man.add_card(it["id"], evidence_fields=["verdict", "rule"])
140
+
141
+ html = render_review_sheet(items, config, screening=..., manifest=man) # raises, or returns
142
+ ```
143
+
144
+ **V9 — evidence reuse.** With `manifest=`, [`preflight()`](https://github.com/sanskrit-lexicon/csl-pyutil/blob/main/csl_pyutil/evidence.py)
145
+ runs against the *finished* document (after `ui_strings`, so it sees exactly what the
146
+ reviewer sees) and raises `PreflightError` before a byte is returned: undeclared
147
+ prior art keyed on the same row ids, cards under the evidence floor with no stated
148
+ reason, Cyrillic/IAST mixed inside one word, SLP1 leaking into human-facing text,
149
+ and structurally impossible sūtra citations. Tune it with `config["preflight"]`
150
+ (`allow_slp1_tokens`, `overlap_threshold`, `skip_prior_art`). Without `manifest=`
151
+ you get a `PreflightWarning` naming the reason — a migration ramp for the pre-0.9.0
152
+ generators that **becomes an error in 1.0.0**; escalate it today with
153
+ `-W error::csl_pyutil.evidence.PreflightWarning`.
154
+
155
+ **V10 — no non-decisions.** `config["non_decision_share"]` is the largest fraction
156
+ of cards the sheet may carry that your own pre-filter already resolved, marked per
157
+ card as `item["machine_resolvable"] = True`. The caller classifies (only it knows its
158
+ domain); the emitter enforces. It **defaults to 0.0** — a card the machine has
159
+ answered does not belong on a human's plate — and a sheet with no flagged item is
160
+ unaffected.
161
+
162
+ Two asymmetries in the gate are deliberate and load-bearing: a conceptual
163
+ `declare_omitted()` can never silence a real file the scan found (only
164
+ `declare_omitted_path()` can), and the SLP1 detector stays **silent** on the
165
+ undecidable all-lowercase case (`agni + deva` is byte-identical in SLP1 and IAST)
166
+ rather than guessing.
167
+
168
+ ## `anatomy` — colour-coded CDSL raw markup
169
+
170
+ ```python
171
+ from csl_pyutil import anatomy
172
+
173
+ panel_body = anatomy.highlight(raw_record, target="As") # tinted, markup still visible
174
+ legend = anatomy.legend_html(parts=["sanskrit", "gloss", "citation"])
175
+ ```
176
+
177
+ A raw CDSL record dumped verbatim into a review card is a wall of punctuation.
178
+ `highlight()` keeps every tag **visible** — the tags are the anatomy — but dims the
179
+ delimiters and colours each payload by part class (Sanskrit form · gloss · citation ·
180
+ grammar · abbreviation · cross-reference · etymology · homonym), outlining any form
181
+ equal to `target`. Written for csl-atlas's xref sheet (H1646), moved here under H1808
182
+ when a second generator needed it; csl-atlas's `scripts/lib/cdsl_anatomy.py` is now a
183
+ re-export shim so there is one canonical copy.
184
+
185
+ Hooks for what a shared module cannot know:
186
+
187
+ | Argument | Use |
188
+ |---|---|
189
+ | `tag_parts={"ab": "crossref"}` | override the tag → part map (PWG wraps *every* abbreviation in `<ab>`; an xref sheet wants the brighter class) |
190
+ | `plain_hook(text)` | reach text the markup does not delimit — NWS-layer cards carry citations as bare text with no `<ls>` around them |
191
+ | `payload_hook(part, inner, attrs)` | render a tagged payload yourself, e.g. `<ls>` as a Cologne source link |
192
+
193
+ The sheet's naming, placement (gitignored `review/`), GTD `@DO` line, and
194
+ `Uprava/REVIEW_SHEETS_INDEX.md` registration are still the caller's job — this
195
+ function only produces the HTML string. See
196
+ [`~/.claude/commands/review-sheet.md`](https://github.com/gasyoun/claude-config/blob/main/commands/review-sheet.md)
197
+ for the full process.
198
+
199
+ ## `integrity_tripwire` — a committed checksum on human-reviewed data
200
+
201
+ ```sh
202
+ python -m csl_pyutil.integrity_tripwire --check --pin data/integrity/<store>.pin.json
203
+ python -m csl_pyutil.integrity_tripwire --extract --pin data/integrity/<store>.pin.json \
204
+ --write-pin --reason "what changed and why"
205
+ ```
206
+
207
+ Three repos have each lost human review work to an automated writer that left a
208
+ file that still parsed: csl-atlas's `--reseed` drops every preserved ruling,
209
+ eleven WhitneyRoots `apply_*` scripts open `src/app_data.json` with `'w'` and no
210
+ lock, and pwg_ru's own review-applying script is unlocked and un-gated. Nobody
211
+ noticed for months, because nothing was watching. This module is what watches.
212
+
213
+ It hashes a **projection** — only the key fields and the reviewed fields of the
214
+ rows a store's predicate calls reviewed — so a reformat, a reorder, or an edit
215
+ to unreviewed content is invisible, and a dropped `reviewer` stamp is not. A
216
+ whole-file SHA-256 was tried first and cried wolf (a 1.29 MB serializer shrink
217
+ at an identical row count), which is how it came to be ignored. Two digests:
218
+ `overlay_sha256` for *did the reviewed content change*, `keyset_sha256` for
219
+ *did the reviewed row set change* — an in-place overwrite moves only the first,
220
+ a deleted row only the second.
221
+
222
+ The rule for "a row is reviewed" is **data, not code**: it lives in the `spec`
223
+ block of each pin, copied from the org census, because the three stores share no
224
+ review stamp. pwg_ru has `reviewer`/`review_status`/`editorial_decision*`;
225
+ csl-atlas needs `reviewStatus` **and** a human `reviewer` (137 of its 147
226
+ status-reviewed rows are agent-attributed, so status alone over-claims human
227
+ review 14.7×); WhitneyRoots has no per-row stamp at all and is reviewed
228
+ file-level.
229
+
230
+ CI runs `--check` and **fails** on a mismatch. A legitimate change regenerates
231
+ the pin in the same commit with a one-line `reason` — that is the whole
232
+ acknowledgement ritual. Exit codes: `0` match, `1` tripwire, `2` broken spec
233
+ (a defect must not be able to look like a clean store).
234
+
235
+ Gitignored stores (pwg_ru's live 26 MB JSONL) commit a derived `--extract`
236
+ projection instead, so CI can check what it cannot see. The live bytes never
237
+ enter git: `redact_fields` names reviewed fields whose value is replaced by
238
+ `sha256:<hex>` of itself, so a field like pwg_ru's `human_review` — the
239
+ curator's verbatim free-text notes, watched precisely because a wipe of it is
240
+ the failure mode — stays fully covered by the digest while publishing nothing
241
+ into a public repo. The hash moves the instant a single character does.
242
+
243
+ ## Tests
244
+
245
+ ```sh
246
+ pip install -e . pytest
247
+ pytest tests -q
248
+ ```
249
+
250
+ `tests/fixtures/` holds 100%-synthetic placeholder content generated only
251
+ through the donor's own render functions — never real translation data (the
252
+ donor's own gitignored/unpublished pwg_ru store must never land in this
253
+ public repo).
254
+
255
+ _Dr. Mārcis Gasūns_
@@ -0,0 +1,68 @@
1
+ # -*- coding: utf-8 -*-
2
+ """csl_pyutil — generic (non-Sanskrit-specific) Python helpers shared across
3
+ the CDSL / Sanskrit-Lexicon repos.
4
+
5
+ Public API
6
+ ----------
7
+ render_review_sheet(items, config, extras=True) self-contained HTML review/
8
+ voting sheet (H925)
9
+ render_review_sheet_packset(items, config, ...) the same sheet split into
10
+ packs of 10 sharing one
11
+ sheet_id, plus an index
12
+ page (V16, H2991)
13
+ anatomy.highlight(raw, target=None, ...) colour-coded CDSL raw-markup
14
+ anatomy.legend_html(parts=None, ...) anatomy for a panel (H1808)
15
+ evidence.EvidenceManifest / evidence.preflight the V9 evidence-reuse gate a
16
+ sheet must pass before it is
17
+ written (H1889)
18
+ RU_UI_STRINGS one-line Russian chrome
19
+ preset for config["ui_strings"]
20
+ (H2854)
21
+ integrity_tripwire.check / .extract committed checksum + key-set
22
+ on human-reviewed overlay
23
+ data, red in CI when a
24
+ seeder wipes it (H2891)
25
+ """
26
+ from csl_pyutil import anatomy, evidence
27
+ from csl_pyutil.evidence import EvidenceManifest, PreflightError, PreflightWarning, preflight
28
+ from csl_pyutil.review_sheet import (render_review_sheet, render_review_sheet_packset,
29
+ esc, mark_cyrillic, RU_UI_STRINGS)
30
+
31
+ # integrity_tripwire is imported LAZILY (PEP 562), not eagerly like its
32
+ # neighbours. Its documented CI invocation is `python -m
33
+ # csl_pyutil.integrity_tripwire --check`, and runpy warns "found in sys.modules
34
+ # after import of package" whenever the package has already pulled the module
35
+ # in — which it would, on every single tripwire run in every consumer repo. A
36
+ # gate whose job is to be believed when it prints RED must not also print a
37
+ # spurious RuntimeWarning every time it prints GREEN.
38
+ _LAZY = {
39
+ "integrity_tripwire": None,
40
+ "TripwireError": "integrity_tripwire",
41
+ "project": "integrity_tripwire",
42
+ "overlay_digest": "integrity_tripwire",
43
+ "keyset_digest": "integrity_tripwire",
44
+ "is_reviewed": "integrity_tripwire",
45
+ "extract": "integrity_tripwire",
46
+ "check": "integrity_tripwire",
47
+ "redact": "integrity_tripwire",
48
+ }
49
+
50
+
51
+ def __getattr__(name):
52
+ if name in _LAZY:
53
+ import importlib
54
+
55
+ module = importlib.import_module("csl_pyutil.integrity_tripwire")
56
+ return module if _LAZY[name] is None else getattr(module, name)
57
+ raise AttributeError("module %r has no attribute %r" % (__name__, name))
58
+
59
+
60
+ def __dir__():
61
+ return sorted(list(globals()) + list(_LAZY))
62
+
63
+ __version__ = "0.24.1"
64
+ __all__ = ["render_review_sheet", "render_review_sheet_packset", "esc", "mark_cyrillic",
65
+ "RU_UI_STRINGS", "anatomy", "evidence",
66
+ "EvidenceManifest", "PreflightError", "PreflightWarning", "preflight",
67
+ "integrity_tripwire", "TripwireError", "project", "overlay_digest",
68
+ "keyset_digest", "is_reviewed", "extract", "check"]
@@ -0,0 +1,237 @@
1
+ # -*- coding: utf-8 -*-
2
+ """anatomy — colour-code the anatomy of a raw CDSL dictionary record.
3
+
4
+ A CDSL record body is a dense mix of SGML-ish tags (``<s>``, ``<lex>``, ``<ls>``,
5
+ ``<ab>``) and brace markers (``{#…#}``, ``{%…%}``) whose classes differ per
6
+ dictionary. Dumped verbatim into a review card it is a wall of punctuation, and a
7
+ reviewer cannot see which clause the judgement rests on (H1646: "add dictionary
8
+ entry anatomy markup, the bright colors for different part of entry").
9
+
10
+ This module keeps the markup fully VISIBLE — the tags are the anatomy, not noise
11
+ to be stripped — but dims the delimiters and colours the payload by part class, so
12
+ the shape of the entry reads at a glance.
13
+
14
+ Provenance: written for csl-atlas's xref sheet (H1646) as
15
+ ``scripts/lib/cdsl_anatomy.py``, lifted here unchanged in behaviour under H1808
16
+ when a SECOND sheet generator (SanskritLexicography's G5 print-readiness lane)
17
+ turned out to need the same thing and had no way to reach it — MG, voting that
18
+ sheet: "why entry anatomy is missing again? It must be a hook". One canonical
19
+ copy in the shared emitter's package is that fix; csl-atlas's file is now a
20
+ re-export shim.
21
+
22
+ Prior art it deliberately reuses rather than re-derives:
23
+
24
+ * Part taxonomy and colour semantics — the ``/entry-anatomy`` skill
25
+ (``entry_anatomy.py``'s ``PARTS`` / ``DICT_MAPS``), which segments a CDSL entry
26
+ into headword · grammar · etymology · sense · citation · cross-reference.
27
+ * The raw-markup highlighting approach and dark palette —
28
+ ``SanskritLexicography/EntryAnatomy/build_entry_anatomy.py`` ``raw_highlight()``
29
+ + ``GENERIC_EXTRA_CSS``, whose colours already sit on a dark panel.
30
+
31
+ Colours are INLINE ``style=`` attributes, not a stylesheet: the output has to drop
32
+ into any panel body of any sheet, including callers that pass no ``extra_css``.
33
+ The container carries ``class="anatomy"`` so the emitter's type scale can still
34
+ reach it (the ``!important`` scale layer outranks the inline ``font`` shorthand).
35
+ """
36
+ import html
37
+ import re
38
+
39
+ #: Part class -> (colour, human label, extra CSS). Dark-panel palette, matching the
40
+ #: review sheet's own --panel2 (#1e222b). Labels drive the rendered legend.
41
+ #: Labels are Russian: the surfaces rendering this legend are review sheets, whose
42
+ #: reviewer reads Russian (H1648). Keys stay English machine identifiers.
43
+ PARTS = {
44
+ "sanskrit": ("#e6c07b", "санскритская форма", ""),
45
+ "gloss": ("#98c379", "перевод / значение", "font-style:italic"),
46
+ "citation": ("#e06c75", "ссылка на источник", ""),
47
+ "grammar": ("#d19a66", "грамматическая помета", ""),
48
+ "abbreviation": ("#c9a227", "сокращение (<ab>)", ""),
49
+ "crossref": ("#56b6c2", "маркер перекрёстной ссылки (cf. / Vgl.)", "font-weight:600"),
50
+ "etymology": ("#61afef", "этимология / когнат", ""),
51
+ "language": ("#7aa2c9", "название языка", ""),
52
+ "taxon": ("#c678dd", "ботаническое / зоологическое название", ""),
53
+ "homonym": ("#b57edc", "номер омонима", ""),
54
+ "structure": ("#7f8c9b", "разделитель значения / раздела", ""),
55
+ }
56
+
57
+ #: Paired content tags -> part class. ``<s>``/``<s1>``/``<s2>`` are MW's Sanskrit
58
+ #: spans; ``<is>`` is PWG's.
59
+ #:
60
+ #: ``<ab>`` defaults to ``abbreviation`` — in PWG it wraps EVERY abbreviation
61
+ #: (``caus.``, ``gerund.``, ``v. a.``), of which cf./Vgl. is one case. csl-atlas's
62
+ #: xref sheet judges cross-references specifically and wants the brighter
63
+ #: ``crossref`` treatment, so it passes ``tag_parts={"ab": "crossref"}``.
64
+ TAG_PARTS = {
65
+ "s": "sanskrit", "s1": "sanskrit", "s2": "sanskrit", "is": "sanskrit",
66
+ "ns": "gloss",
67
+ "ls": "citation",
68
+ "lex": "grammar",
69
+ "ab": "abbreviation",
70
+ "etym": "etymology",
71
+ "lang": "language",
72
+ "bot": "taxon", "zoo": "taxon",
73
+ "hom": "homonym",
74
+ }
75
+
76
+ #: Brace markers -> part class. ``{#…#}`` Sanskrit, ``{%…%}`` gloss (PWG/AP90),
77
+ #: ``{@…@}`` a sense/section number.
78
+ BRACE_PARTS = {"#": "sanskrit", "%": "gloss", "@": "structure"}
79
+
80
+ _DELIM = "#5c6773" # tag/brace delimiters — present but receded
81
+ _ACCENT = "#ff7b72" # Vedic accent marks inside a Sanskrit form
82
+ _PIPE = "#e06c75" # the ¦ head/body separator
83
+ _PLAIN = "#d8dce2" # untagged running text
84
+
85
+ _SCANNER = re.compile(
86
+ r"(?P<pair><(?P<tag>s1|s2|s|is|ns|ls|lex|ab|etym|lang|bot|zoo|hom)\b(?P<attrs>[^>]*)>"
87
+ r"(?P<inner>.*?)</(?P=tag)>)"
88
+ r"|(?P<brace>\{(?P<bk>[#%@])(?P<binner>.*?)(?P=bk)\})"
89
+ r"|(?P<other><[^>]+>)"
90
+ r"|(?P<pipe>¦)",
91
+ re.DOTALL,
92
+ )
93
+
94
+ #: Vedic accent / length marks CDSL writes inside SLP1 forms. Stripped only when
95
+ #: comparing a form against the highlight target.
96
+ _ACCENT_CHARS = "/\\^~"
97
+
98
+
99
+ def _strip_accents(text):
100
+ return "".join(ch for ch in text if ch not in _ACCENT_CHARS)
101
+
102
+
103
+ def _span(text, colour, extra="", title=None, escape=True):
104
+ body = html.escape(text) if escape else text
105
+ style = "color:%s" % colour
106
+ if extra:
107
+ style += ";" + extra
108
+ attrs = ' title="%s"' % html.escape(title) if title else ""
109
+ return '<span style="%s"%s>%s</span>' % (style, attrs, body)
110
+
111
+
112
+ def _sanskrit_body(inner, target_norm):
113
+ """Colour a Sanskrit payload, marking accents and the highlight target."""
114
+ colour, _label, extra = PARTS["sanskrit"]
115
+ pieces = []
116
+ for ch in inner:
117
+ if ch in _ACCENT_CHARS:
118
+ pieces.append(_span(ch, _ACCENT, "font-weight:700"))
119
+ else:
120
+ pieces.append(html.escape(ch))
121
+ body = "".join(pieces)
122
+ if target_norm and _strip_accents(inner).strip() == target_norm:
123
+ # This span IS the form the card is asking about.
124
+ return (
125
+ '<span style="background:rgba(86,182,194,.22);outline:1px solid #56b6c2;'
126
+ 'border-radius:3px;padding:0 2px" title="цель перекрёстной ссылки, о которой спрашивает эта карточка">'
127
+ + _span(body, colour, extra, escape=False)
128
+ + "</span>"
129
+ )
130
+ return _span(body, colour, extra, escape=False)
131
+
132
+
133
+ def highlight(raw, target=None, *, tag_parts=None, plain_hook=None, payload_hook=None):
134
+ """Return colour-coded HTML for one raw CDSL record body.
135
+
136
+ ``target`` is the SLP1 form under judgement; every Sanskrit span in the
137
+ record equal to it (ignoring accent marks) is outlined.
138
+
139
+ ``tag_parts`` overrides the tag -> part mapping for this call (merged over
140
+ ``TAG_PARTS``), e.g. ``{"ab": "crossref"}``.
141
+
142
+ ``plain_hook(text) -> html`` is called for every UNTAGGED run. A caller uses
143
+ it to reach content the markup does not delimit — bare citations, bracketed
144
+ diasystem tags — and is responsible for escaping what it returns. Return
145
+ ``None`` to fall through to the default plain rendering.
146
+
147
+ ``payload_hook(part, inner, attrs) -> html`` is called for each tagged
148
+ payload before it is coloured; a caller returns ready HTML (e.g. an ``<ls>``
149
+ citation rendered as a source link) or ``None`` to fall through.
150
+ """
151
+ text = str(raw or "")
152
+ target_norm = _strip_accents(str(target or "").strip()) or None
153
+ parts_map = dict(TAG_PARTS)
154
+ if tag_parts:
155
+ parts_map.update(tag_parts)
156
+
157
+ def plain(chunk):
158
+ if plain_hook is not None:
159
+ got = plain_hook(chunk)
160
+ if got is not None:
161
+ return got
162
+ return _span(chunk, _PLAIN)
163
+
164
+ out = []
165
+ pos = 0
166
+ for m in _SCANNER.finditer(text):
167
+ if m.start() > pos:
168
+ out.append(plain(text[pos:m.start()]))
169
+ if m.group("pair"):
170
+ tag, attrs, inner = m.group("tag"), m.group("attrs") or "", m.group("inner")
171
+ part = parts_map.get(tag, "structure")
172
+ colour, label, extra = PARTS[part]
173
+ out.append(_span("<%s%s>" % (tag, attrs), _DELIM, title=label))
174
+ hooked = payload_hook(part, inner, attrs) if payload_hook is not None else None
175
+ if hooked is not None:
176
+ out.append(hooked)
177
+ elif part == "sanskrit":
178
+ out.append(_sanskrit_body(inner, target_norm))
179
+ else:
180
+ out.append(_span(inner, colour, extra, title=label))
181
+ out.append(_span("</%s>" % tag, _DELIM, title=label))
182
+ elif m.group("brace"):
183
+ bk, inner = m.group("bk"), m.group("binner")
184
+ part = BRACE_PARTS.get(bk, "structure")
185
+ colour, label, extra = PARTS[part]
186
+ out.append(_span("{" + bk, _DELIM, title=label))
187
+ if part == "sanskrit":
188
+ out.append(_sanskrit_body(inner, target_norm))
189
+ else:
190
+ out.append(_span(inner, colour, extra, title=label))
191
+ out.append(_span(bk + "}", _DELIM, title=label))
192
+ elif m.group("other"):
193
+ # <div n="v">, <info lex="m"/> and friends: structural, kept visible but quiet.
194
+ out.append(_span(m.group("other"), _DELIM, title="structural markup"))
195
+ else:
196
+ out.append(_span("¦", _PIPE, "font-weight:700", title="headword / body separator"))
197
+ pos = m.end()
198
+ if pos < len(text):
199
+ out.append(plain(text[pos:]))
200
+ return (
201
+ '<div class="anatomy" style="background:#20242a;border-radius:6px;padding:12px 14px;'
202
+ 'font:12.5px/1.9 Consolas,\'Cascadia Mono\',monospace;white-space:pre-wrap;'
203
+ 'word-break:break-word">' + "".join(out) + "</div>"
204
+ )
205
+
206
+
207
+ def legend_html(parts=None, extra_chips=()):
208
+ """A compact swatch legend for the part classes, for one place on the sheet.
209
+
210
+ ``parts`` restricts (and orders) the classes shown — pass only the ones the
211
+ sheet's dictionary actually uses. ``extra_chips`` appends caller chips as
212
+ ``(colour, label)`` pairs, for conventions this module does not own.
213
+ """
214
+ keys = list(parts or PARTS)
215
+ chips = []
216
+ for key in keys:
217
+ colour, label, extra = PARTS[key]
218
+ chips.append(
219
+ '<span style="display:inline-block;margin:0 10px 4px 0;white-space:nowrap">'
220
+ '<span style="display:inline-block;width:10px;height:10px;border-radius:2px;'
221
+ 'background:%s;margin-right:5px;vertical-align:baseline"></span>'
222
+ '<span style="color:%s;%s">%s</span></span>' % (colour, colour, extra, html.escape(label))
223
+ )
224
+ chips.append(
225
+ '<span style="display:inline-block;margin:0 10px 4px 0;white-space:nowrap">'
226
+ '<span style="display:inline-block;width:10px;height:10px;border-radius:2px;'
227
+ 'background:rgba(86,182,194,.22);outline:1px solid #56b6c2;margin-right:5px"></span>'
228
+ '<span style="color:#56b6c2">цель перекрёстной ссылки</span></span>'
229
+ )
230
+ for colour, label in extra_chips:
231
+ chips.append(
232
+ '<span style="display:inline-block;margin:0 10px 4px 0;white-space:nowrap">'
233
+ '<span style="display:inline-block;width:10px;height:10px;border-radius:2px;'
234
+ 'background:%s;margin-right:5px;vertical-align:baseline"></span>'
235
+ '<span style="color:%s">%s</span></span>' % (colour, colour, html.escape(label))
236
+ )
237
+ return '<div style="font-size:12px;line-height:1.9">' + "".join(chips) + "</div>"