backdraft 0.2.0__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.
- backdraft/__init__.py +13 -0
- backdraft/bind/__init__.py +37 -0
- backdraft/bind/binder.py +653 -0
- backdraft/bind/cli.py +133 -0
- backdraft/bind/evidence.py +232 -0
- backdraft/bind/verify/__init__.py +30 -0
- backdraft/bind/verify/base.py +70 -0
- backdraft/bind/verify/entail.py +156 -0
- backdraft/bind/verify/overlap.py +114 -0
- backdraft/bind/verify/recompute.py +45 -0
- backdraft/bind/verify/value_trace.py +352 -0
- backdraft/cli.py +341 -0
- backdraft/cli_context.py +152 -0
- backdraft/credentials.py +106 -0
- backdraft/extract/__init__.py +37 -0
- backdraft/extract/base.py +184 -0
- backdraft/extract/pdf_text.py +124 -0
- backdraft/extract/text.py +44 -0
- backdraft/extract/vlm.py +175 -0
- backdraft/extract/vlm_settings.py +197 -0
- backdraft/extract/xlsx.py +244 -0
- backdraft/gate/__init__.py +39 -0
- backdraft/gate/cli.py +131 -0
- backdraft/gate/reader.py +592 -0
- backdraft/gate/searcher.py +123 -0
- backdraft/kernel/__init__.py +17 -0
- backdraft/kernel/artifact.py +234 -0
- backdraft/kernel/chunking.py +162 -0
- backdraft/kernel/claims.py +142 -0
- backdraft/kernel/errors.py +35 -0
- backdraft/kernel/hashing.py +89 -0
- backdraft/kernel/model.py +336 -0
- backdraft/kernel/tokens.py +291 -0
- backdraft/registry/__init__.py +39 -0
- backdraft/registry/ledger.py +82 -0
- backdraft/registry/schema.sql +90 -0
- backdraft/registry/store.py +915 -0
- backdraft/render/__init__.py +31 -0
- backdraft/render/_text.py +69 -0
- backdraft/render/cli.py +107 -0
- backdraft/render/footnotes.py +172 -0
- backdraft/render/html.py +1211 -0
- backdraft/render/markdown.py +313 -0
- backdraft/render/placement.py +105 -0
- backdraft/render/sidecar.py +164 -0
- backdraft-0.2.0.dist-info/METADATA +253 -0
- backdraft-0.2.0.dist-info/RECORD +50 -0
- backdraft-0.2.0.dist-info/WHEEL +4 -0
- backdraft-0.2.0.dist-info/entry_points.txt +2 -0
- backdraft-0.2.0.dist-info/licenses/LICENSE +21 -0
backdraft/render/html.py
ADDED
|
@@ -0,0 +1,1211 @@
|
|
|
1
|
+
"""The artifact: one HTML file that carries the document, its receipts, and
|
|
2
|
+
its evidence.
|
|
3
|
+
|
|
4
|
+
Designed for two readers in three disclosure layers (DESIGN.md, 2026-07-28):
|
|
5
|
+
|
|
6
|
+
* **The document.** Editorial typography on plain paper; claims wear a faint
|
|
7
|
+
underline and a small numbered mark. **Success is silent** — a fully-resolved
|
|
8
|
+
artifact says nothing about citations on its face; failures earn one plain
|
|
9
|
+
sentence under the title and a wavy underline on their claims.
|
|
10
|
+
* **The footnote.** Click a claim: its reference card opens in the evidence
|
|
11
|
+
rail — the source's words rendered as prose, then the source itself: the
|
|
12
|
+
actual page image, or the cited cell highlighted in a spreadsheet window that
|
|
13
|
+
opens into the full sheet. Multiple citations sit behind a per-source
|
|
14
|
+
selector. No tokens, no hashes, no jargon at this layer.
|
|
15
|
+
* **The record.** A `Record` disclosure per citation: token, sha256, and each
|
|
16
|
+
verifier's verdict in plain words. The machine-readable record is the JSON
|
|
17
|
+
island; `$legend` teaches a cold reader to decode it.
|
|
18
|
+
|
|
19
|
+
The load-bearing constraint is **no network, single file** — enforced by a CSP
|
|
20
|
+
`meta` tag (`default-src 'none'`), not promised. Inline JS is progressive
|
|
21
|
+
enhancement over a CSS-and-anchor baseline: with scripts stripped, claims
|
|
22
|
+
degrade to footnote jumps into the Notes section and every receipt is still
|
|
23
|
+
readable.
|
|
24
|
+
|
|
25
|
+
Evidence comes from `report.evidence` (assembled at bind, carried in the
|
|
26
|
+
sidecar); a report without evidence renders quotes alone. The island embeds the
|
|
27
|
+
sidecar payload minus `evidence.pages[*].data` — the same bytes are already in
|
|
28
|
+
the page as image elements, and `LEGEND["evidence"]` licenses the omission.
|
|
29
|
+
"""
|
|
30
|
+
|
|
31
|
+
from __future__ import annotations
|
|
32
|
+
|
|
33
|
+
import difflib
|
|
34
|
+
import html as html_module
|
|
35
|
+
import json
|
|
36
|
+
import re
|
|
37
|
+
import urllib.parse
|
|
38
|
+
from typing import Any
|
|
39
|
+
|
|
40
|
+
from ..kernel.artifact import FORMAT, sidecar
|
|
41
|
+
from ..kernel.model import BindReport, Citation, CitationStatus, Claim, Verdict
|
|
42
|
+
from . import markdown
|
|
43
|
+
from .placement import Placement, locate
|
|
44
|
+
|
|
45
|
+
__all__ = ["render", "ISLAND_ID", "SHEETS_ISLAND_ID", "STYLESHEET", "FLAME_PATH"]
|
|
46
|
+
|
|
47
|
+
ISLAND_ID = "backdraft-artifact"
|
|
48
|
+
"""The id of the `<script type="application/json">` island holding the record."""
|
|
49
|
+
|
|
50
|
+
SHEETS_ISLAND_ID = "bd-sheets"
|
|
51
|
+
"""The id of the JSON island holding full cited-sheet values for the sheet view."""
|
|
52
|
+
|
|
53
|
+
FLAME_PATH = (
|
|
54
|
+
"M44 3 C38 8 30 10 26 16 C23.5 20 24.5 24 27 26.5 "
|
|
55
|
+
"C20.5 28 14.5 33 14 41 C13.3 51.5 22 59.5 33 59.5 "
|
|
56
|
+
"C44 59.5 51.5 51.5 51 41.5 C50.6 33.5 45 29 42.5 22.5 "
|
|
57
|
+
"C40.8 18 42.5 9 44 3 Z "
|
|
58
|
+
"M34 34 C38 38.5 39.5 43.5 37.5 47.5 C35 52 28.5 51.5 26.5 47 "
|
|
59
|
+
"C25 43.5 27.5 40 30 38.5 C31.8 37.5 33.2 36 34 34 Z"
|
|
60
|
+
)
|
|
61
|
+
"""The backdraft mark: a flame swept backward by returning air. The canonical
|
|
62
|
+
vector is `assets/backdraft-mark.svg`; this is its in-code copy for the favicon
|
|
63
|
+
and the sign-off."""
|
|
64
|
+
|
|
65
|
+
CELL_RE = re.compile(r"^(?P<sheet>[^!]+)!(?P<ref>[A-Z]{1,3}\d+)$")
|
|
66
|
+
PAGE_RE = re.compile(r"^p(?P<page>\d+)(?:\.c\d+)?$")
|
|
67
|
+
IMAGE_TAG_RE = re.compile(r"\[(?:IMAGE|CHART|MAP):[^\]]*\]\s*")
|
|
68
|
+
SUBTITLE_RE = re.compile(r"^\*([^*].*?)\*\s*$")
|
|
69
|
+
|
|
70
|
+
_STATUS_RANK = {
|
|
71
|
+
CitationStatus.RESOLVED: 0,
|
|
72
|
+
CitationStatus.NOT_SHOWN: 1,
|
|
73
|
+
CitationStatus.DRIFTED: 2,
|
|
74
|
+
CitationStatus.UNRESOLVED: 3,
|
|
75
|
+
CitationStatus.MALFORMED: 4,
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
_STATUS_SENTENCE = {
|
|
79
|
+
CitationStatus.UNRESOLVED: (
|
|
80
|
+
"This citation names nothing in the sources; the claim is untraced."
|
|
81
|
+
),
|
|
82
|
+
CitationStatus.NOT_SHOWN: "The source exists, but the writer was never shown it.",
|
|
83
|
+
CitationStatus.DRIFTED: "The source has changed since this was written.",
|
|
84
|
+
CitationStatus.MALFORMED: "The citation is malformed and could not be checked.",
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def _esc(value: Any) -> str:
|
|
89
|
+
return html_module.escape(str(value), quote=True)
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
# ---- small text helpers -----------------------------------------------------
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def split_subtitle(source: str) -> tuple[str, str | None]:
|
|
96
|
+
"""An italic line directly under the `# title` is the document's subtitle.
|
|
97
|
+
|
|
98
|
+
Returns (source without that line, subtitle text or None). The subtitle is
|
|
99
|
+
a fill-in slot the author owns; the masthead shows it under the title.
|
|
100
|
+
"""
|
|
101
|
+
lines = source.splitlines(keepends=True)
|
|
102
|
+
seen_title = False
|
|
103
|
+
for index, line in enumerate(lines):
|
|
104
|
+
if not line.strip():
|
|
105
|
+
continue
|
|
106
|
+
if line.startswith("# ") and not seen_title:
|
|
107
|
+
seen_title = True
|
|
108
|
+
continue
|
|
109
|
+
if seen_title and (match := SUBTITLE_RE.match(line.strip())):
|
|
110
|
+
return "".join(lines[:index] + lines[index + 1:]), match.group(1)
|
|
111
|
+
break
|
|
112
|
+
return source, None
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def fmt_number(raw: str) -> str:
|
|
116
|
+
"""Sheet values display formatted; the record keeps the verbatim value."""
|
|
117
|
+
try:
|
|
118
|
+
value = float(raw)
|
|
119
|
+
except (TypeError, ValueError):
|
|
120
|
+
return raw
|
|
121
|
+
if value == int(value) and abs(value) < 1e15:
|
|
122
|
+
return f"{int(value):,}"
|
|
123
|
+
if 0 < abs(value) < 1:
|
|
124
|
+
return f"{value:.4f}"
|
|
125
|
+
return f"{value:,.0f}"
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def _is_number(raw: str) -> bool:
|
|
129
|
+
try:
|
|
130
|
+
float(raw)
|
|
131
|
+
return True
|
|
132
|
+
except (TypeError, ValueError):
|
|
133
|
+
return False
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def humanize_sheet(slug: str) -> str:
|
|
137
|
+
fixed = {"dy": "DY", "t12": "T12", "ttm": "TTM", "noi": "NOI"}
|
|
138
|
+
return " ".join(
|
|
139
|
+
fixed.get(word, word.capitalize()) for word in slug.replace("-", " ").split()
|
|
140
|
+
)
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def source_title(slug: str, docs: dict) -> str:
|
|
144
|
+
"""A human name for a source: its filename's stem, humanized.
|
|
145
|
+
|
|
146
|
+
A slug-shaped stem (`t12-summary`, `underwriting-model`) reads as a
|
|
147
|
+
machine identifier where a reader expects a name, so it is title-cased
|
|
148
|
+
through the same fixed-caps table sheet names use. A stem with its own
|
|
149
|
+
casing is somebody's chosen name and passes through untouched.
|
|
150
|
+
"""
|
|
151
|
+
entry = docs.get(slug)
|
|
152
|
+
base = (
|
|
153
|
+
str(entry.get("filename", slug)).rsplit(".", 1)[0].replace("_", " ")
|
|
154
|
+
if entry
|
|
155
|
+
else slug
|
|
156
|
+
)
|
|
157
|
+
if re.fullmatch(r"[a-z0-9][a-z0-9 \-]*", base):
|
|
158
|
+
return humanize_sheet(base.replace(" ", "-"))
|
|
159
|
+
return base
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def md_html(text: str) -> str:
|
|
163
|
+
"""Snippet or page text rendered as markdown for human display."""
|
|
164
|
+
return markdown.to_html(IMAGE_TAG_RE.sub("", text), [])
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def table_heavy(text: str) -> bool:
|
|
168
|
+
lines = [line for line in text.splitlines() if line.strip()]
|
|
169
|
+
if not lines:
|
|
170
|
+
return False
|
|
171
|
+
return sum(1 for line in lines if line.lstrip().startswith("|")) > len(lines) * 0.4
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
def worst_status(claim: Claim) -> CitationStatus:
|
|
175
|
+
statuses = [citation.status for citation in claim.citations]
|
|
176
|
+
if not statuses:
|
|
177
|
+
return CitationStatus.UNRESOLVED
|
|
178
|
+
return max(statuses, key=_STATUS_RANK.__getitem__)
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
def location(anchor, docs: dict) -> str:
|
|
182
|
+
locator = str(anchor.locator)
|
|
183
|
+
if match := CELL_RE.match(locator):
|
|
184
|
+
return f"{_esc(humanize_sheet(match['sheet']))} · {match['ref']}"
|
|
185
|
+
if match := PAGE_RE.match(locator):
|
|
186
|
+
if docs.get(anchor.slug, {}).get("media_type") == "xlsx":
|
|
187
|
+
return "sheet"
|
|
188
|
+
return f"Page {match['page']}"
|
|
189
|
+
return _esc(locator)
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
def short_loc(anchor, docs: dict) -> str:
|
|
193
|
+
"""Compact label for the source selector: `Page 6`, `D24`, `Sheet`."""
|
|
194
|
+
if anchor is None:
|
|
195
|
+
return "untraced"
|
|
196
|
+
locator = str(anchor.locator)
|
|
197
|
+
if match := CELL_RE.match(locator):
|
|
198
|
+
return match["ref"]
|
|
199
|
+
if match := PAGE_RE.match(locator):
|
|
200
|
+
if docs.get(anchor.slug, {}).get("media_type") == "xlsx":
|
|
201
|
+
return "Sheet"
|
|
202
|
+
return f"Page {match['page']}"
|
|
203
|
+
return locator
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
# ---- verdict language -------------------------------------------------------
|
|
207
|
+
|
|
208
|
+
_VALUES_FOUND_RE = re.compile(r"^(\d+) value\(s\) found in snippet$")
|
|
209
|
+
_NOT_FOUND_RE = re.compile(r"^not found in snippet: (.+)$")
|
|
210
|
+
_ROUNDED_RE = re.compile(r"^rounded match: (.+)$")
|
|
211
|
+
_TOKENS_RE = re.compile(r"^(\d+)/(\d+) claim tokens in snippet")
|
|
212
|
+
_QUOTED_MISSING_RE = re.compile(r"^quoted span not in snippet: (.+)$")
|
|
213
|
+
_QUOTED_CASE_RE = re.compile(r"^quoted span differs in case: (.+)$")
|
|
214
|
+
_QUOTED_OK_RE = re.compile(r"^(\d+) quoted span\(s\) verbatim in snippet$")
|
|
215
|
+
|
|
216
|
+
_METHOD_LABEL = {
|
|
217
|
+
"value-trace": "Figures",
|
|
218
|
+
"overlap": "Wording",
|
|
219
|
+
"entail": "Reading",
|
|
220
|
+
"recompute": "Math",
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
def humanize_verdict(verdict: Verdict) -> tuple[str, str]:
|
|
225
|
+
"""(label, sentence) for the record layer. Debug strings become prose."""
|
|
226
|
+
label = _METHOD_LABEL.get(verdict.method, verdict.method)
|
|
227
|
+
detail = verdict.detail or ""
|
|
228
|
+
if verdict.method == "value-trace":
|
|
229
|
+
if match := _VALUES_FOUND_RE.match(detail):
|
|
230
|
+
count = int(match.group(1))
|
|
231
|
+
sentence = (
|
|
232
|
+
"The figure in this claim appears in this source."
|
|
233
|
+
if count == 1
|
|
234
|
+
else f"All {count} figures in this claim appear in this source."
|
|
235
|
+
)
|
|
236
|
+
elif match := _NOT_FOUND_RE.match(detail):
|
|
237
|
+
sentence = f"Not found in this source: {match.group(1)}."
|
|
238
|
+
elif match := _ROUNDED_RE.match(detail):
|
|
239
|
+
sentence = f"Matched only after rounding: {match.group(1)}."
|
|
240
|
+
else:
|
|
241
|
+
sentence = detail
|
|
242
|
+
return label, sentence
|
|
243
|
+
if verdict.method == "overlap":
|
|
244
|
+
if match := _TOKENS_RE.match(detail):
|
|
245
|
+
found, total = int(match.group(1)), int(match.group(2))
|
|
246
|
+
sentence = (
|
|
247
|
+
"None of the claim's wording appears in this source."
|
|
248
|
+
if found == 0
|
|
249
|
+
else f"{found} of the claim's {total} words appear in this source."
|
|
250
|
+
)
|
|
251
|
+
elif match := _QUOTED_MISSING_RE.match(detail):
|
|
252
|
+
sentence = f"Quoted phrase not found verbatim in this source: {match.group(1)}."
|
|
253
|
+
elif match := _QUOTED_CASE_RE.match(detail):
|
|
254
|
+
sentence = f"Quoted phrase differs from this source in casing: {match.group(1)}."
|
|
255
|
+
elif match := _QUOTED_OK_RE.match(detail):
|
|
256
|
+
sentence = "Every quoted phrase appears verbatim in this source."
|
|
257
|
+
else:
|
|
258
|
+
sentence = detail
|
|
259
|
+
return label, sentence
|
|
260
|
+
return label, detail or str(verdict.status)
|
|
261
|
+
|
|
262
|
+
|
|
263
|
+
# ---- drift ------------------------------------------------------------------
|
|
264
|
+
|
|
265
|
+
_WORD_RE = re.compile(r"\s+|\S+")
|
|
266
|
+
|
|
267
|
+
|
|
268
|
+
def _word_diff(before: str, after: str) -> tuple[str, str]:
|
|
269
|
+
"""Escaped HTML for both snippets, with `<del>`/`<ins>` on what moved."""
|
|
270
|
+
old_words = _WORD_RE.findall(before)
|
|
271
|
+
new_words = _WORD_RE.findall(after)
|
|
272
|
+
matcher = difflib.SequenceMatcher(a=old_words, b=new_words, autojunk=False)
|
|
273
|
+
old: list[str] = []
|
|
274
|
+
new: list[str] = []
|
|
275
|
+
for tag, i1, i2, j1, j2 in matcher.get_opcodes():
|
|
276
|
+
old_text = html_module.escape("".join(old_words[i1:i2]))
|
|
277
|
+
new_text = html_module.escape("".join(new_words[j1:j2]))
|
|
278
|
+
if tag == "equal":
|
|
279
|
+
old.append(old_text)
|
|
280
|
+
new.append(new_text)
|
|
281
|
+
continue
|
|
282
|
+
if old_text:
|
|
283
|
+
old.append(f"<del>{old_text}</del>")
|
|
284
|
+
if new_text:
|
|
285
|
+
new.append(f"<ins>{new_text}</ins>")
|
|
286
|
+
return "".join(old), "".join(new)
|
|
287
|
+
|
|
288
|
+
|
|
289
|
+
def _drift_block(citation: Citation) -> str:
|
|
290
|
+
if citation.drifted_from is None:
|
|
291
|
+
return ""
|
|
292
|
+
now = citation.anchor.receipt.snippet if citation.anchor else ""
|
|
293
|
+
old_html, new_html = _word_diff(citation.drifted_from, now)
|
|
294
|
+
return (
|
|
295
|
+
'<div class="drift">'
|
|
296
|
+
f'<div class="drift-row"><span>as cited</span><p>{old_html}</p></div>'
|
|
297
|
+
f'<div class="drift-row"><span>now</span><p>{new_html}</p></div>'
|
|
298
|
+
"</div>"
|
|
299
|
+
)
|
|
300
|
+
|
|
301
|
+
|
|
302
|
+
# ---- the renderer -----------------------------------------------------------
|
|
303
|
+
|
|
304
|
+
|
|
305
|
+
def render(source: str, report: BindReport, *, title: str | None = None) -> str:
|
|
306
|
+
"""Render the artifact: `source` as a document, `report` as its evidence.
|
|
307
|
+
|
|
308
|
+
`title` overrides the page title, which otherwise comes from the document's
|
|
309
|
+
first heading and falls back to the bound document's filename.
|
|
310
|
+
"""
|
|
311
|
+
evidence = report.evidence or {}
|
|
312
|
+
docs: dict = evidence.get("documents", {})
|
|
313
|
+
source, subtitle = split_subtitle(source)
|
|
314
|
+
|
|
315
|
+
placements = locate(source, report.claims)
|
|
316
|
+
placed = [p for p in placements if p.placed]
|
|
317
|
+
orphans = [p for p in placements if not p.placed]
|
|
318
|
+
|
|
319
|
+
page_store: dict[str, dict] = {}
|
|
320
|
+
|
|
321
|
+
spans = []
|
|
322
|
+
for placement in placed:
|
|
323
|
+
flag = "" if worst_status(placement.claim) is CitationStatus.RESOLVED else " flagged"
|
|
324
|
+
text = markdown.inline(placement.claim.text)
|
|
325
|
+
spans.append(markdown.Span(
|
|
326
|
+
start=placement.start, end=placement.end,
|
|
327
|
+
html=(
|
|
328
|
+
f'<a class="claim{flag}" id="claim-{placement.number}" '
|
|
329
|
+
f'href="#note-{placement.number}" data-claim="{placement.number}">'
|
|
330
|
+
f'{text}<sup class="mark">{placement.number}</sup></a>'
|
|
331
|
+
),
|
|
332
|
+
))
|
|
333
|
+
body = markdown.to_html(source, spans)
|
|
334
|
+
|
|
335
|
+
cards = "\n".join(_card(p, docs, evidence, page_store) for p in placements)
|
|
336
|
+
notes = "\n".join(_note(p, docs) for p in placements)
|
|
337
|
+
|
|
338
|
+
failures = [
|
|
339
|
+
(p, c)
|
|
340
|
+
for p in placements
|
|
341
|
+
for c in p.claim.citations
|
|
342
|
+
if c.status is not CitationStatus.RESOLVED
|
|
343
|
+
]
|
|
344
|
+
unmatched = [p for p in placements if p.claim.unmatched or not p.claim.citations]
|
|
345
|
+
n_cites = sum(len(p.claim.citations) for p in placements)
|
|
346
|
+
|
|
347
|
+
heading = title or _title(source, report)
|
|
348
|
+
subtitle_html = f'<p class="subtitle">{_esc(subtitle)}</p>' if subtitle else ""
|
|
349
|
+
alarm_bits: list[str] = []
|
|
350
|
+
if failures:
|
|
351
|
+
alarm_bits.append(
|
|
352
|
+
f"{len(failures)} of {n_cites} citations could not be traced to a source"
|
|
353
|
+
)
|
|
354
|
+
if unmatched:
|
|
355
|
+
alarm_bits.append(f"{len(unmatched)} claims carry no citation")
|
|
356
|
+
if orphans:
|
|
357
|
+
alarm_bits.append(f"{len(orphans)} recorded claims are not in this document")
|
|
358
|
+
alarm_html = (
|
|
359
|
+
f'<p class="alarmline">{_esc("; ".join(alarm_bits))} — see the notes.</p>'
|
|
360
|
+
if alarm_bits
|
|
361
|
+
else ""
|
|
362
|
+
)
|
|
363
|
+
|
|
364
|
+
return PAGE.format(
|
|
365
|
+
title=_esc(heading),
|
|
366
|
+
favicon=_favicon(),
|
|
367
|
+
css=STYLESHEET,
|
|
368
|
+
js=SCRIPT,
|
|
369
|
+
subtitle=subtitle_html,
|
|
370
|
+
alarm=alarm_html,
|
|
371
|
+
body=body,
|
|
372
|
+
cards=cards,
|
|
373
|
+
notes=notes,
|
|
374
|
+
sources=_sources_index(placements, docs),
|
|
375
|
+
store=_page_store_html(page_store),
|
|
376
|
+
mark_svg=(
|
|
377
|
+
f'<svg class="bd-mark" width="14" height="14" viewBox="0 0 64 64">'
|
|
378
|
+
f'<path fill="#676767" fill-rule="evenodd" d="{FLAME_PATH}"/></svg>'
|
|
379
|
+
),
|
|
380
|
+
sheets_island_id=SHEETS_ISLAND_ID,
|
|
381
|
+
sheets=_escape_island(json.dumps(evidence.get("sheets", {}), ensure_ascii=False)),
|
|
382
|
+
island_id=ISLAND_ID,
|
|
383
|
+
island=_island(report),
|
|
384
|
+
format=FORMAT,
|
|
385
|
+
)
|
|
386
|
+
|
|
387
|
+
|
|
388
|
+
def _title(source: str, report: BindReport) -> str:
|
|
389
|
+
for line in source.splitlines():
|
|
390
|
+
if line.startswith("# "):
|
|
391
|
+
return line[2:].strip()
|
|
392
|
+
if line.strip():
|
|
393
|
+
break
|
|
394
|
+
return report.doc_path.rsplit("/", 1)[-1]
|
|
395
|
+
|
|
396
|
+
|
|
397
|
+
def _favicon() -> str:
|
|
398
|
+
svg = (
|
|
399
|
+
'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">'
|
|
400
|
+
'<rect width="64" height="64" rx="13" fill="#FBFAF6"/>'
|
|
401
|
+
'<g transform="translate(32 32) scale(0.82) translate(-32 -32)">'
|
|
402
|
+
f'<path fill="#282828" fill-rule="evenodd" d="{FLAME_PATH}"/></g></svg>'
|
|
403
|
+
)
|
|
404
|
+
return "data:image/svg+xml," + urllib.parse.quote(svg)
|
|
405
|
+
|
|
406
|
+
|
|
407
|
+
def _escape_island(payload: str) -> str:
|
|
408
|
+
"""JSON escaped so no byte sequence can close the script element."""
|
|
409
|
+
return payload.replace("<", "\\u003c").replace(">", "\\u003e").replace("&", "\\u0026")
|
|
410
|
+
|
|
411
|
+
|
|
412
|
+
def _island(report: BindReport) -> str:
|
|
413
|
+
"""The record island: the sidecar payload, minus embedded image bytes.
|
|
414
|
+
|
|
415
|
+
`render --to json` carries the full evidence; the island omits
|
|
416
|
+
`evidence.pages[*].data` because the identical bytes are in the document as
|
|
417
|
+
image elements, and `$legend` documents the omission.
|
|
418
|
+
"""
|
|
419
|
+
payload = sidecar(report)
|
|
420
|
+
evidence = payload.get("evidence")
|
|
421
|
+
if isinstance(evidence, dict) and isinstance(evidence.get("pages"), dict):
|
|
422
|
+
evidence = dict(evidence)
|
|
423
|
+
evidence["pages"] = {
|
|
424
|
+
key: {k: v for k, v in entry.items() if k != "data"}
|
|
425
|
+
for key, entry in evidence["pages"].items()
|
|
426
|
+
}
|
|
427
|
+
payload = {**payload, "evidence": evidence}
|
|
428
|
+
return _escape_island(json.dumps(payload, indent=2, ensure_ascii=False))
|
|
429
|
+
|
|
430
|
+
|
|
431
|
+
# ---- evidence ---------------------------------------------------------------
|
|
432
|
+
|
|
433
|
+
|
|
434
|
+
def _page_plate(anchor, page: dict, docs: dict, store: dict[str, dict]) -> str:
|
|
435
|
+
match = PAGE_RE.match(str(anchor.locator))
|
|
436
|
+
key = f"ev-{anchor.slug}-p{match['page']}"
|
|
437
|
+
store[key] = page
|
|
438
|
+
caption = f"{_esc(source_title(anchor.slug, docs))}, page {match['page']}"
|
|
439
|
+
return (
|
|
440
|
+
f'<figure class="plate"><img data-ev="{key}" alt="{caption}" '
|
|
441
|
+
f'width="{int(page["width"])}" height="{int(page["height"])}">'
|
|
442
|
+
f"<figcaption><span>{caption}</span>"
|
|
443
|
+
f'<span class="hintcap">click to enlarge</span></figcaption></figure>'
|
|
444
|
+
)
|
|
445
|
+
|
|
446
|
+
|
|
447
|
+
def _page_store_html(store: dict[str, dict]) -> str:
|
|
448
|
+
images = "".join(
|
|
449
|
+
f'<img id="{key}" '
|
|
450
|
+
f'src="data:image/{page["format"]};base64,{page["data"]}" alt="">'
|
|
451
|
+
for key, page in store.items()
|
|
452
|
+
)
|
|
453
|
+
return f'<div id="store" hidden aria-hidden="true">{images}</div>'
|
|
454
|
+
|
|
455
|
+
|
|
456
|
+
def _window_table(window: dict, slug: str) -> str:
|
|
457
|
+
cited = window.get("cited")
|
|
458
|
+
cited_col = re.match(r"[A-Z]+", cited).group() if cited else None
|
|
459
|
+
cited_row = re.search(r"\d+", cited).group() if cited else None
|
|
460
|
+
head = "".join(
|
|
461
|
+
f'<th class="citedcol">{c}</th>' if c == cited_col else f"<th>{c}</th>"
|
|
462
|
+
for c in window["cols"]
|
|
463
|
+
)
|
|
464
|
+
rows = []
|
|
465
|
+
for row in window["rows"]:
|
|
466
|
+
cells = []
|
|
467
|
+
for column in window["cols"]:
|
|
468
|
+
raw = row["cells"].get(column, "")
|
|
469
|
+
ref = f"{column}{row['n']}"
|
|
470
|
+
classes = []
|
|
471
|
+
if ref == cited:
|
|
472
|
+
classes.append("cited")
|
|
473
|
+
if _is_number(raw):
|
|
474
|
+
classes.append("num")
|
|
475
|
+
klass = f' class="{" ".join(classes)}"' if classes else ""
|
|
476
|
+
shown = fmt_number(raw)
|
|
477
|
+
tip = f' title="{_esc(raw)}"' if raw != shown else ""
|
|
478
|
+
cells.append(f"<td{klass}{tip}>{_esc(shown)}</td>")
|
|
479
|
+
row_class = ' class="citedrow"' if str(row["n"]) == cited_row else ""
|
|
480
|
+
rows.append(f'<tr{row_class}><th>{row["n"]}</th>{"".join(cells)}</tr>')
|
|
481
|
+
caption = humanize_sheet(window["sheet"]) + (f", {cited}" if cited else "")
|
|
482
|
+
sheet_key = f"{slug}:{window['sheet']}"
|
|
483
|
+
return (
|
|
484
|
+
f'<figure class="plate grid" data-sheet="{_esc(sheet_key)}" '
|
|
485
|
+
f'data-cited="{_esc(cited or "")}" role="button" tabindex="0" '
|
|
486
|
+
f'aria-label="Open full sheet"><div class="gridwrap"><table>'
|
|
487
|
+
f"<thead><tr><th></th>{head}</tr></thead><tbody>{''.join(rows)}</tbody>"
|
|
488
|
+
f"</table></div><figcaption><span>{_esc(caption)}</span>"
|
|
489
|
+
f'<span class="hintcap">click to open the sheet</span></figcaption></figure>'
|
|
490
|
+
)
|
|
491
|
+
|
|
492
|
+
|
|
493
|
+
def _tabs(source_pane: str, text_pane: str, source_label: str) -> str:
|
|
494
|
+
return (
|
|
495
|
+
'<div class="evidence"><div class="tabs" role="tablist">'
|
|
496
|
+
f'<button class="tab on" data-pane="src">{source_label}</button>'
|
|
497
|
+
'<button class="tab" data-pane="text">Extracted text</button></div>'
|
|
498
|
+
f'<div class="pane on" data-pane="src">{source_pane}</div>'
|
|
499
|
+
f'<div class="pane" data-pane="text">{text_pane}</div></div>'
|
|
500
|
+
)
|
|
501
|
+
|
|
502
|
+
|
|
503
|
+
def _record_block(citation: Citation) -> str:
|
|
504
|
+
rows = [
|
|
505
|
+
f'<div class="rr"><span>token</span><code>{_esc(citation.token)}</code></div>'
|
|
506
|
+
]
|
|
507
|
+
if citation.anchor is not None:
|
|
508
|
+
rows.append(
|
|
509
|
+
f'<div class="rr"><span>sha256</span>'
|
|
510
|
+
f"<code>{_esc(citation.anchor.receipt.snippet_sha256)}</code></div>"
|
|
511
|
+
)
|
|
512
|
+
if citation.error:
|
|
513
|
+
rows.append(
|
|
514
|
+
f'<div class="rr"><span>error</span><code>{_esc(citation.error)}</code></div>'
|
|
515
|
+
)
|
|
516
|
+
for verdict in citation.verdicts:
|
|
517
|
+
label, sentence = humanize_verdict(verdict)
|
|
518
|
+
rows.append(
|
|
519
|
+
f'<div class="rr verdict v-{verdict.status}"><span>{_esc(label)}</span>'
|
|
520
|
+
f'<code class="vs">{_esc(str(verdict.status))}</code> {_esc(sentence)}</div>'
|
|
521
|
+
)
|
|
522
|
+
return f'<details class="record"><summary>Record</summary>{"".join(rows)}</details>'
|
|
523
|
+
|
|
524
|
+
|
|
525
|
+
def _status_sentence(citation: Citation) -> str:
|
|
526
|
+
sentence = _STATUS_SENTENCE.get(citation.status)
|
|
527
|
+
return f'<p class="alarm">{sentence}</p>' if sentence else ""
|
|
528
|
+
|
|
529
|
+
|
|
530
|
+
def _citation(
|
|
531
|
+
citation: Citation, docs: dict, evidence: dict,
|
|
532
|
+
store: dict[str, dict], *, index: int, on: bool,
|
|
533
|
+
) -> str:
|
|
534
|
+
wrap = f'<div class="cite{" on" if on else ""}" data-cite="{index}">'
|
|
535
|
+
if citation.anchor is None:
|
|
536
|
+
return f"{wrap}{_status_sentence(citation)}{_record_block(citation)}</div>"
|
|
537
|
+
anchor = citation.anchor
|
|
538
|
+
locator = str(anchor.locator)
|
|
539
|
+
parts = [
|
|
540
|
+
wrap,
|
|
541
|
+
f'<p class="src"><span class="doc">{_esc(source_title(anchor.slug, docs))}</span>'
|
|
542
|
+
f'<span class="loc">{location(anchor, docs)}</span></p>',
|
|
543
|
+
_status_sentence(citation),
|
|
544
|
+
_drift_block(citation),
|
|
545
|
+
]
|
|
546
|
+
|
|
547
|
+
cell = CELL_RE.match(locator)
|
|
548
|
+
page_m = PAGE_RE.match(locator)
|
|
549
|
+
is_sheet_doc = docs.get(anchor.slug, {}).get("media_type") == "xlsx"
|
|
550
|
+
raw = f'<pre class="rawtext">{_esc(anchor.receipt.snippet[:4000])}</pre>'
|
|
551
|
+
if cell:
|
|
552
|
+
window = evidence.get("windows", {}).get(f"{anchor.slug}:{locator}")
|
|
553
|
+
if window:
|
|
554
|
+
parts.append(_tabs(_window_table(window, anchor.slug), raw, "Cells"))
|
|
555
|
+
else:
|
|
556
|
+
parts.append(raw)
|
|
557
|
+
elif page_m and not is_sheet_doc:
|
|
558
|
+
heavy = table_heavy(anchor.receipt.snippet)
|
|
559
|
+
if not heavy:
|
|
560
|
+
parts.append(
|
|
561
|
+
f'<blockquote class="quote">{md_html(anchor.receipt.snippet)}</blockquote>'
|
|
562
|
+
)
|
|
563
|
+
page = evidence.get("pages", {}).get(f"{anchor.slug}:p{page_m['page']}")
|
|
564
|
+
pagetext = evidence.get("pagetexts", {}).get(f"{anchor.slug}:p{page_m['page']}")
|
|
565
|
+
text_pane = (
|
|
566
|
+
f'<div class="pagetext">{md_html(pagetext)}</div>' if pagetext else raw
|
|
567
|
+
)
|
|
568
|
+
if page:
|
|
569
|
+
parts.append(_tabs(_page_plate(anchor, page, docs, store), text_pane, "Page"))
|
|
570
|
+
elif heavy:
|
|
571
|
+
# No page image and a snippet the quote suppressed: the evidence
|
|
572
|
+
# must still be visible — the page text, or the verbatim snippet.
|
|
573
|
+
parts.append(text_pane)
|
|
574
|
+
elif page_m: # a whole-sheet citation
|
|
575
|
+
window = evidence.get("windows", {}).get(f"{anchor.slug}:p{page_m['page']}")
|
|
576
|
+
if window:
|
|
577
|
+
parts.append(_tabs(_window_table(window, anchor.slug), raw, "Cells"))
|
|
578
|
+
else:
|
|
579
|
+
parts.append(raw)
|
|
580
|
+
|
|
581
|
+
parts.append(_record_block(citation))
|
|
582
|
+
parts.append("</div>")
|
|
583
|
+
return "".join(parts)
|
|
584
|
+
|
|
585
|
+
|
|
586
|
+
_TYPE_LABEL = {"xlsx": "Excel", "pdf": "PDF", "text": "Text"}
|
|
587
|
+
|
|
588
|
+
|
|
589
|
+
def _card(placement: Placement, docs: dict, evidence: dict, store: dict[str, dict]) -> str:
|
|
590
|
+
citations = placement.claim.citations
|
|
591
|
+
selector = ""
|
|
592
|
+
if len(citations) > 1:
|
|
593
|
+
locs = [short_loc(c.anchor, docs) for c in citations]
|
|
594
|
+
buttons = []
|
|
595
|
+
for index, citation in enumerate(citations):
|
|
596
|
+
label = _esc(locs[index])
|
|
597
|
+
if locs.count(locs[index]) > 1 and citation.anchor is not None:
|
|
598
|
+
media = docs.get(citation.anchor.slug, {}).get("media_type", "")
|
|
599
|
+
label = f"{label} · {_TYPE_LABEL.get(media, media)}"
|
|
600
|
+
media = (
|
|
601
|
+
docs.get(citation.anchor.slug, {}).get("media_type", "")
|
|
602
|
+
if citation.anchor
|
|
603
|
+
else ""
|
|
604
|
+
)
|
|
605
|
+
kind = {"xlsx": "excel", "pdf": "pdf"}.get(media, "")
|
|
606
|
+
buttons.append(
|
|
607
|
+
f'<button class="{"on " if index == 0 else ""}{kind}" '
|
|
608
|
+
f'data-cite="{index}">{label}</button>'
|
|
609
|
+
)
|
|
610
|
+
selector = (
|
|
611
|
+
f'<p class="srccount">{len(citations)} sources</p>'
|
|
612
|
+
f'<div class="srcsel">{"".join(buttons)}</div>'
|
|
613
|
+
)
|
|
614
|
+
if citations:
|
|
615
|
+
inner = "".join(
|
|
616
|
+
_citation(c, docs, evidence, store, index=i, on=(i == 0))
|
|
617
|
+
for i, c in enumerate(citations)
|
|
618
|
+
)
|
|
619
|
+
else:
|
|
620
|
+
inner = (
|
|
621
|
+
'<div class="cite on"><p class="alarm">This claim carries no citation '
|
|
622
|
+
"and no evidence.</p></div>"
|
|
623
|
+
)
|
|
624
|
+
return (
|
|
625
|
+
f'<article class="card" id="card-{placement.number}" hidden>'
|
|
626
|
+
f'<header><span class="cardno">Reference {placement.number}</span>'
|
|
627
|
+
f'<button class="close" data-close aria-label="Close">×</button></header>'
|
|
628
|
+
f"{selector}{inner}</article>"
|
|
629
|
+
)
|
|
630
|
+
|
|
631
|
+
|
|
632
|
+
def _note(placement: Placement, docs: dict) -> str:
|
|
633
|
+
parts = []
|
|
634
|
+
if placement.claim.unmatched or not placement.claim.citations:
|
|
635
|
+
parts.append(
|
|
636
|
+
'<p class="alarm">This claim carries no citation and no evidence.</p>'
|
|
637
|
+
f'<blockquote class="quote">{_esc(placement.claim.text[:200])}</blockquote>'
|
|
638
|
+
)
|
|
639
|
+
if not placement.placed:
|
|
640
|
+
parts.append(
|
|
641
|
+
'<p class="alarm">This claim is in the record but its words are not '
|
|
642
|
+
"in the document as rendered.</p>"
|
|
643
|
+
f'<blockquote class="quote">{_esc(placement.claim.text[:200])}</blockquote>'
|
|
644
|
+
)
|
|
645
|
+
for citation in placement.claim.citations:
|
|
646
|
+
if citation.anchor is None:
|
|
647
|
+
parts.append(_status_sentence(citation) + _record_block(citation))
|
|
648
|
+
continue
|
|
649
|
+
snippet = citation.anchor.receipt.snippet
|
|
650
|
+
quote_html = ""
|
|
651
|
+
if not table_heavy(snippet):
|
|
652
|
+
short = snippet if len(snippet) <= 360 else snippet[:360].rsplit(" ", 1)[0] + " …"
|
|
653
|
+
short = short.replace("**", "").replace("*", "")
|
|
654
|
+
quote_html = f'<blockquote class="quote">{md_html(short)}</blockquote>'
|
|
655
|
+
parts.append(
|
|
656
|
+
f'<p class="src"><span class="doc">{_esc(source_title(citation.anchor.slug, docs))}</span>'
|
|
657
|
+
f'<span class="loc">{location(citation.anchor, docs)}</span></p>'
|
|
658
|
+
f"{_status_sentence(citation)}{_drift_block(citation)}"
|
|
659
|
+
f"{quote_html}{_record_block(citation)}"
|
|
660
|
+
)
|
|
661
|
+
return (
|
|
662
|
+
f'<li class="note" id="note-{placement.number}">'
|
|
663
|
+
f'<a class="backref" href="#claim-{placement.number}" '
|
|
664
|
+
f'title="Back to the claim">{placement.number}</a>'
|
|
665
|
+
f'<div>{"".join(parts)}</div></li>'
|
|
666
|
+
)
|
|
667
|
+
|
|
668
|
+
|
|
669
|
+
def _sources_index(placements: list[Placement], docs: dict) -> str:
|
|
670
|
+
counts: dict[str, int] = {}
|
|
671
|
+
for placement in placements:
|
|
672
|
+
for citation in placement.claim.citations:
|
|
673
|
+
if citation.anchor is not None:
|
|
674
|
+
counts[citation.anchor.slug] = counts.get(citation.anchor.slug, 0) + 1
|
|
675
|
+
items = []
|
|
676
|
+
for slug, count in sorted(counts.items(), key=lambda item: -item[1]):
|
|
677
|
+
entry = docs.get(slug, {})
|
|
678
|
+
meta = _esc(entry.get("filename", slug))
|
|
679
|
+
items.append(
|
|
680
|
+
f'<li><span class="doc">{_esc(source_title(slug, docs))}</span>'
|
|
681
|
+
f'<span class="filemeta">{meta} · '
|
|
682
|
+
f"{count} citation{'s' if count != 1 else ''}</span></li>"
|
|
683
|
+
)
|
|
684
|
+
return "".join(items)
|
|
685
|
+
|
|
686
|
+
|
|
687
|
+
# ---- the page ---------------------------------------------------------------
|
|
688
|
+
|
|
689
|
+
STYLESHEET = """
|
|
690
|
+
*,*::before,*::after{box-sizing:border-box}
|
|
691
|
+
:root{
|
|
692
|
+
color-scheme:light;
|
|
693
|
+
--paper:#FFFFFF; --ink:#282828; --muted:#676767; --faint:#A3A19A;
|
|
694
|
+
--hover:rgba(40,40,40,.05); --active:rgba(40,40,40,.09);
|
|
695
|
+
--underline:rgba(40,40,40,.16);
|
|
696
|
+
--hairline:#E8E5DD; --hairline-strong:#D6D2C6;
|
|
697
|
+
--notebook:#FBFAF6; --notebook-line:rgba(40,40,40,.05);
|
|
698
|
+
--sel:#1F7244; --sel-soft:#EAF3EE;
|
|
699
|
+
--excel-line:#E3E2DC; --excel-head:#F4F3EE;
|
|
700
|
+
--hl:#F5E6AE;
|
|
701
|
+
--alarm:#A63A2E;
|
|
702
|
+
--serif:'Iowan Old Style','Iowan Old Style BT',Palatino,'Palatino Linotype',Georgia,serif;
|
|
703
|
+
--sans:ui-sans-serif,system-ui,-apple-system,'Segoe UI',Helvetica,sans-serif;
|
|
704
|
+
--mono:ui-monospace,'SF Mono',Menlo,Consolas,monospace;
|
|
705
|
+
--rail-w:calc(50vw - 14px);
|
|
706
|
+
}
|
|
707
|
+
html{-webkit-text-size-adjust:100%}
|
|
708
|
+
body{margin:0;background:var(--notebook);color:var(--ink);
|
|
709
|
+
font-family:var(--serif);font-size:17px;line-height:1.6;
|
|
710
|
+
font-synthesis:none;text-rendering:optimizeLegibility}
|
|
711
|
+
.frame{display:grid;grid-template-columns:minmax(0,1fr) 28px var(--rail-w)}
|
|
712
|
+
.pagecol{grid-column:1;min-width:0;max-width:44rem;width:100%;
|
|
713
|
+
padding:4rem 2.75rem 6rem;margin:0 auto}
|
|
714
|
+
.divider{grid-column:2;cursor:col-resize;touch-action:none}
|
|
715
|
+
.divider::before{content:'';display:block;position:sticky;top:12.5vh;height:75vh;
|
|
716
|
+
width:1px;margin:0 auto;background:var(--hairline-strong);transition:background .15s}
|
|
717
|
+
.divider:hover::before,.divider.dragging::before{background:var(--ink);width:2px}
|
|
718
|
+
.railcol{grid-column:3;min-width:0;
|
|
719
|
+
background:
|
|
720
|
+
repeating-linear-gradient(0deg,var(--notebook-line) 0 1px,transparent 1px 18px),
|
|
721
|
+
repeating-linear-gradient(90deg,var(--notebook-line) 0 1px,transparent 1px 18px),
|
|
722
|
+
var(--notebook)}
|
|
723
|
+
.rail{position:sticky;top:0;max-height:100vh;overflow-y:auto;
|
|
724
|
+
font-family:var(--sans);font-size:.84rem;line-height:1.55;
|
|
725
|
+
padding:2.5rem 2.25rem}
|
|
726
|
+
a{color:inherit}
|
|
727
|
+
|
|
728
|
+
/* ---- masthead ---- */
|
|
729
|
+
.masthead{margin:0 0 3rem;text-align:center}
|
|
730
|
+
.masthead h1{font-size:1.85rem;line-height:1.35;margin:0 0 .55rem;font-weight:600}
|
|
731
|
+
.subtitle{font-family:var(--serif);font-size:.95rem;color:var(--muted);margin:0}
|
|
732
|
+
.alarmline{font-family:var(--sans);font-size:.78rem;color:var(--alarm);
|
|
733
|
+
margin:.6rem 0 0}
|
|
734
|
+
|
|
735
|
+
/* ---- the document ---- */
|
|
736
|
+
.doc h1{display:none}
|
|
737
|
+
.doc h2{font-family:var(--serif);font-size:1.2rem;font-weight:600;
|
|
738
|
+
margin:2.3rem 0 .7rem}
|
|
739
|
+
.doc p{margin:0 0 1.05rem}
|
|
740
|
+
.doc ul,.doc ol{margin:0 0 1.05rem;padding-left:1.4rem}
|
|
741
|
+
.doc blockquote{margin:1.1rem 0;padding:.1rem 0 .1rem 1rem;
|
|
742
|
+
border-left:2px solid var(--hairline);color:var(--muted)}
|
|
743
|
+
.doc pre{font-family:var(--mono);font-size:.78rem;line-height:1.5;overflow-x:auto;
|
|
744
|
+
background:var(--paper);border:1px solid var(--hairline);border-radius:3px;
|
|
745
|
+
padding:.8rem .95rem;margin:0 0 1.05rem}
|
|
746
|
+
.doc code{font-family:var(--mono);font-size:.85em}
|
|
747
|
+
.table-wrap{overflow-x:auto;margin:0 0 1.1rem}
|
|
748
|
+
.doc table{border-collapse:collapse;font-family:var(--sans);font-size:.84rem}
|
|
749
|
+
.doc th,.doc td{padding:.35rem .65rem;border-bottom:1px solid var(--hairline);
|
|
750
|
+
text-align:left}
|
|
751
|
+
.doc .t-right{text-align:right}
|
|
752
|
+
.doc .t-center{text-align:center}
|
|
753
|
+
.bd-image{color:var(--muted);font-style:italic}
|
|
754
|
+
.claim{color:inherit;cursor:pointer;
|
|
755
|
+
text-decoration:underline;text-decoration-color:var(--underline);
|
|
756
|
+
text-decoration-thickness:1px;text-underline-offset:3px;
|
|
757
|
+
transition:background-color .15s,text-decoration-color .15s}
|
|
758
|
+
.claim:hover{background:var(--hover);text-decoration-color:var(--ink)}
|
|
759
|
+
.claim.active{background:var(--active);text-decoration-color:var(--ink)}
|
|
760
|
+
.claim.flagged{text-decoration-style:wavy;text-decoration-color:var(--alarm)}
|
|
761
|
+
.mark{font-family:var(--sans);font-size:.58em;font-weight:600;color:var(--muted);
|
|
762
|
+
margin-left:.14em}
|
|
763
|
+
.claim:hover .mark,.claim.active .mark{color:var(--ink)}
|
|
764
|
+
.claim.flagged .mark{color:var(--alarm)}
|
|
765
|
+
|
|
766
|
+
/* ---- rail resting ---- */
|
|
767
|
+
.resting{background:var(--paper);border:1px solid var(--hairline);border-radius:3px;
|
|
768
|
+
padding:1.2rem 1.35rem;box-shadow:0 1px 3px rgba(40,40,40,.05)}
|
|
769
|
+
.resting h2{font-size:.66rem;letter-spacing:.14em;text-transform:uppercase;
|
|
770
|
+
margin:0 0 .9rem;color:var(--faint);font-weight:600}
|
|
771
|
+
.resting ul{list-style:none;margin:0;padding:0}
|
|
772
|
+
.resting li{margin:.75rem 0}
|
|
773
|
+
.resting .doc{display:block;color:var(--ink);font-weight:600;font-size:.84rem}
|
|
774
|
+
.resting .filemeta{display:block;font-size:.72rem;color:var(--muted);
|
|
775
|
+
overflow-wrap:anywhere;margin-top:.15rem}
|
|
776
|
+
.resting .hint{margin:1.2rem 0 0;font-size:.74rem;color:var(--muted);line-height:1.5}
|
|
777
|
+
|
|
778
|
+
/* ---- the card ---- */
|
|
779
|
+
.card{background:var(--paper);border:1px solid var(--hairline);border-radius:3px;
|
|
780
|
+
box-shadow:0 1px 3px rgba(40,40,40,.05),0 8px 24px rgba(40,40,40,.06);
|
|
781
|
+
padding:1.05rem 1.35rem 1.15rem;animation:rise .16s ease;
|
|
782
|
+
resize:vertical;overflow:auto;min-height:12rem;max-height:82vh}
|
|
783
|
+
@keyframes rise{from{opacity:0;transform:translateY(.35rem)}to{opacity:1;transform:none}}
|
|
784
|
+
.card header{display:flex;align-items:baseline;justify-content:space-between;
|
|
785
|
+
padding:0 0 .2rem}
|
|
786
|
+
.cardno{font-family:var(--sans);font-size:.74rem;font-weight:600;color:var(--ink);
|
|
787
|
+
letter-spacing:.02em}
|
|
788
|
+
.close{background:none;border:0;font-size:1.05rem;line-height:1;color:var(--faint);
|
|
789
|
+
cursor:pointer;padding:.1rem .3rem}
|
|
790
|
+
.close:hover{color:var(--ink)}
|
|
791
|
+
|
|
792
|
+
/* source selector */
|
|
793
|
+
.srccount{font-family:var(--sans);font-size:.68rem;color:var(--faint);
|
|
794
|
+
margin:.1rem 0 0}
|
|
795
|
+
.srcsel{display:flex;gap:1.3rem;border-bottom:1px solid var(--hairline);
|
|
796
|
+
margin:.3rem 0 .95rem}
|
|
797
|
+
.srcsel button{font-family:var(--sans);font-size:.76rem;font-weight:500;
|
|
798
|
+
color:var(--muted);background:none;border:0;padding:.45rem 0;cursor:pointer;
|
|
799
|
+
border-bottom:2px solid transparent;margin-bottom:-1px;letter-spacing:.01em}
|
|
800
|
+
.srcsel button:hover{color:var(--ink)}
|
|
801
|
+
.srcsel button.on{color:var(--ink);font-weight:600;border-bottom-color:var(--ink)}
|
|
802
|
+
.srcsel button.on.excel{border-bottom-color:var(--sel)}
|
|
803
|
+
.srcsel button.on.pdf{border-bottom-color:#9E3B2F}
|
|
804
|
+
|
|
805
|
+
.cite{display:none;padding:.15rem 0}
|
|
806
|
+
.cite.on{display:block}
|
|
807
|
+
.src{margin:.2rem 0 .6rem;display:flex;align-items:baseline;gap:.6rem;flex-wrap:wrap}
|
|
808
|
+
.src .doc{font-weight:600;color:var(--ink);font-size:.88rem}
|
|
809
|
+
.src .loc{font-size:.72rem;color:var(--faint)}
|
|
810
|
+
.alarm{color:var(--alarm);font-size:.78rem;margin:.35rem 0}
|
|
811
|
+
|
|
812
|
+
.quote{margin:.35rem 0 .75rem;padding:.05rem 0 .05rem .9rem;
|
|
813
|
+
border-left:2px solid var(--hairline-strong);font-family:var(--serif);
|
|
814
|
+
font-size:.9rem;line-height:1.55;color:var(--ink);
|
|
815
|
+
max-height:15rem;overflow-y:auto}
|
|
816
|
+
.quote p{margin:0 0 .5rem}.quote p:last-child{margin:0}
|
|
817
|
+
.quote h1,.quote h2,.quote h3,.quote h4{font-family:var(--sans);font-size:.64rem;
|
|
818
|
+
letter-spacing:.1em;text-transform:uppercase;color:var(--faint);
|
|
819
|
+
margin:.6rem 0 .3rem;font-weight:600}
|
|
820
|
+
.quote ul,.quote ol{margin:.2rem 0 .5rem;padding-left:1.2rem}
|
|
821
|
+
.quote strong{font-weight:600}
|
|
822
|
+
.quote .bd-image{display:none}
|
|
823
|
+
|
|
824
|
+
/* drift: the two snippets, word-diffed */
|
|
825
|
+
.drift{margin:.5rem 0 .7rem;font-size:.84rem}
|
|
826
|
+
.drift-row{display:flex;gap:.6rem;margin:.3rem 0}
|
|
827
|
+
.drift-row span{flex:0 0 3.6rem;font-family:var(--sans);font-size:.6rem;
|
|
828
|
+
color:var(--faint);text-transform:uppercase;letter-spacing:.08em;padding-top:.2rem}
|
|
829
|
+
.drift-row p{margin:0;font-family:var(--serif);overflow-wrap:anywhere}
|
|
830
|
+
.drift del{text-decoration:line-through;text-decoration-thickness:1px;
|
|
831
|
+
color:var(--alarm);background:rgba(166,58,46,.08)}
|
|
832
|
+
.drift ins{text-decoration:none;color:var(--sel);background:var(--sel-soft)}
|
|
833
|
+
|
|
834
|
+
/* view toggle */
|
|
835
|
+
.evidence{margin:.4rem 0 .3rem}
|
|
836
|
+
.tabs{display:flex;gap:.9rem;margin-bottom:.55rem}
|
|
837
|
+
.tab{font-family:var(--sans);font-size:.68rem;font-weight:500;color:var(--faint);
|
|
838
|
+
background:none;border:0;padding:.15rem 0;cursor:pointer;
|
|
839
|
+
border-bottom:1px solid transparent}
|
|
840
|
+
.tab:hover{color:var(--ink)}
|
|
841
|
+
.tab.on{color:var(--ink);border-bottom-color:var(--ink)}
|
|
842
|
+
.pane{display:none}
|
|
843
|
+
.pane.on{display:block}
|
|
844
|
+
.pagetext,.rawtext{border:1px solid var(--hairline);
|
|
845
|
+
border-radius:2px;padding:.7rem .85rem;background:var(--paper)}
|
|
846
|
+
.pagetext{font-family:var(--serif);font-size:.86rem;line-height:1.55}
|
|
847
|
+
.pagetext h1,.pagetext h2,.pagetext h3{font-family:var(--sans);font-size:.64rem;
|
|
848
|
+
letter-spacing:.1em;text-transform:uppercase;color:var(--faint);
|
|
849
|
+
margin:.7rem 0 .35rem;font-weight:600}
|
|
850
|
+
.pagetext h1:first-child,.pagetext h2:first-child{margin-top:0}
|
|
851
|
+
.pagetext p{margin:0 0 .5rem}
|
|
852
|
+
.pagetext ul{margin:.2rem 0 .5rem;padding-left:1.2rem}
|
|
853
|
+
.pagetext .table-wrap{overflow-x:auto;margin:.4rem 0}
|
|
854
|
+
.pagetext table{border-collapse:collapse;font-family:var(--sans);font-size:.7rem;
|
|
855
|
+
white-space:nowrap}
|
|
856
|
+
.pagetext th,.pagetext td{border:1px solid var(--hairline);padding:.2rem .5rem;
|
|
857
|
+
text-align:left}
|
|
858
|
+
.pagetext th{background:var(--excel-head);font-weight:600}
|
|
859
|
+
.rawtext{font-family:var(--mono);font-size:.68rem;line-height:1.5;white-space:pre-wrap;
|
|
860
|
+
overflow-wrap:anywhere;color:var(--ink);margin:.4rem 0}
|
|
861
|
+
|
|
862
|
+
/* evidence plates */
|
|
863
|
+
.plate{margin:.1rem 0 .3rem;cursor:zoom-in}
|
|
864
|
+
.plate img{display:block;width:100%;height:auto;border:1px solid var(--hairline);
|
|
865
|
+
border-radius:2px;background:#fff}
|
|
866
|
+
.plate:hover img{border-color:var(--hairline-strong)}
|
|
867
|
+
.plate figcaption{font-family:var(--sans);font-size:.68rem;color:var(--muted);
|
|
868
|
+
margin-top:.4rem;display:flex;justify-content:space-between;gap:.6rem}
|
|
869
|
+
.hintcap{color:var(--ink);opacity:0;transition:opacity .15s;white-space:nowrap}
|
|
870
|
+
.plate:hover .hintcap{opacity:.7}
|
|
871
|
+
|
|
872
|
+
/* the excel experience */
|
|
873
|
+
.grid{cursor:pointer}
|
|
874
|
+
.grid .gridwrap{overflow-x:auto;border:1px solid var(--excel-line);border-radius:2px;
|
|
875
|
+
background:var(--paper)}
|
|
876
|
+
.grid table,.sheettable{border-collapse:separate;border-spacing:0;
|
|
877
|
+
font-family:var(--sans);font-size:.72rem;line-height:1.6;min-width:100%;
|
|
878
|
+
font-variant-numeric:tabular-nums}
|
|
879
|
+
.grid th,.sheettable th{font-weight:500;color:#6B6B66;background:var(--excel-head);
|
|
880
|
+
padding:.1rem .55rem;text-align:center;
|
|
881
|
+
border-bottom:1px solid var(--excel-line);border-right:1px solid var(--excel-line);
|
|
882
|
+
font-size:.66rem}
|
|
883
|
+
.grid td,.sheettable td{padding:.1rem .55rem;
|
|
884
|
+
border-bottom:1px solid var(--excel-line);border-right:1px solid var(--excel-line);
|
|
885
|
+
white-space:nowrap;max-width:14rem;overflow:hidden;text-overflow:ellipsis;
|
|
886
|
+
background:var(--paper);text-align:left}
|
|
887
|
+
.grid td.num,.sheettable td.num{text-align:right}
|
|
888
|
+
.grid td.cited,.sheettable td.cited{background:var(--sel-soft);
|
|
889
|
+
box-shadow:inset 0 0 0 2px var(--sel);font-weight:600}
|
|
890
|
+
.grid tbody th{position:sticky;left:0}
|
|
891
|
+
tr.citedrow th{background:var(--sel-soft);color:var(--sel);font-weight:600}
|
|
892
|
+
th.citedcol{background:var(--sel-soft);color:var(--sel);font-weight:600}
|
|
893
|
+
|
|
894
|
+
/* the record layer */
|
|
895
|
+
.record{margin:.5rem 0 .1rem;font-size:.7rem}
|
|
896
|
+
.record summary{cursor:pointer;color:var(--faint);letter-spacing:.1em;
|
|
897
|
+
text-transform:uppercase;font-size:.6rem;font-weight:600;list-style:none}
|
|
898
|
+
.record summary::before{content:'\\25B8';margin-right:.35rem;font-size:.55rem}
|
|
899
|
+
.record[open] summary::before{content:'\\25BE'}
|
|
900
|
+
.record[open] summary{margin-bottom:.35rem}
|
|
901
|
+
.rr{display:flex;gap:.55rem;margin:.22rem 0;color:var(--muted);overflow-wrap:anywhere}
|
|
902
|
+
.rr span{flex:0 0 4.4rem;text-transform:uppercase;letter-spacing:.08em;font-size:.58rem;
|
|
903
|
+
padding-top:.1rem}
|
|
904
|
+
.rr code{font-family:var(--mono);font-size:.66rem;color:var(--ink)}
|
|
905
|
+
.rr.verdict code{text-transform:uppercase;letter-spacing:.05em}
|
|
906
|
+
.v-fail code{color:var(--alarm)} .v-partial code{color:#96690F}
|
|
907
|
+
.v-skip code{color:var(--faint)}
|
|
908
|
+
|
|
909
|
+
/* ---- end matter ---- */
|
|
910
|
+
.endmatter{margin-top:4rem;border-top:1px solid var(--hairline);padding-top:1.6rem;
|
|
911
|
+
font-family:var(--sans)}
|
|
912
|
+
.endmatter h2{font-size:.66rem;letter-spacing:.14em;text-transform:uppercase;
|
|
913
|
+
color:var(--faint);font-weight:600;margin:0 0 1rem}
|
|
914
|
+
.srclist{list-style:none;margin:0 0 2.2rem;padding:0;font-size:.8rem}
|
|
915
|
+
.srclist li{margin:.55rem 0}
|
|
916
|
+
.srclist .doc{font-weight:600}
|
|
917
|
+
.srclist .filemeta{display:block;color:var(--muted);font-size:.7rem}
|
|
918
|
+
.notes{list-style:none;margin:0;padding:0;font-size:.78rem}
|
|
919
|
+
.note{display:flex;gap:1rem;padding:.9rem 0;border-top:1px solid var(--hairline);
|
|
920
|
+
cursor:pointer}
|
|
921
|
+
.note:hover{background:var(--hover)}
|
|
922
|
+
.note:target{background:var(--hover)}
|
|
923
|
+
.note > div{flex:1;min-width:0}
|
|
924
|
+
.backref{flex:0 0 1.4rem;font-family:var(--mono);font-size:.72rem;
|
|
925
|
+
color:var(--faint);text-decoration:none;text-align:right;padding-top:.15rem}
|
|
926
|
+
.backref:hover{color:var(--ink)}
|
|
927
|
+
.note .quote{font-size:.84rem}
|
|
928
|
+
.colophon{margin-top:2.8rem;color:var(--muted);font-size:.72rem;
|
|
929
|
+
line-height:1.55;display:flex;align-items:center;gap:.45rem}
|
|
930
|
+
.bd-mark{flex:0 0 auto}
|
|
931
|
+
|
|
932
|
+
/* ---- overlays ---- */
|
|
933
|
+
.overlay{position:fixed;inset:0;background:rgba(24,23,20,.85);z-index:90;
|
|
934
|
+
display:none;align-items:center;justify-content:center;padding:1.5rem}
|
|
935
|
+
.overlay.open{display:flex}
|
|
936
|
+
.overlay > img{max-width:100%;max-height:100%;border-radius:2px;cursor:zoom-out;
|
|
937
|
+
background:#fff}
|
|
938
|
+
.sheetbox{background:var(--paper);border-radius:4px;
|
|
939
|
+
width:min(78rem,100%);height:min(48rem,100%);display:flex;flex-direction:column;
|
|
940
|
+
overflow:hidden}
|
|
941
|
+
.sheetbox header{display:flex;align-items:center;justify-content:space-between;
|
|
942
|
+
font-family:var(--sans);font-size:.78rem;font-weight:600;
|
|
943
|
+
padding:.7rem 1rem;border-bottom:1px solid var(--hairline)}
|
|
944
|
+
.sheetbox header .close{font-size:1.2rem}
|
|
945
|
+
.sheetscroll{overflow:auto;flex:1;background:var(--paper)}
|
|
946
|
+
.sheettable thead th{position:sticky;top:0;z-index:2}
|
|
947
|
+
.sheettable tbody th{position:sticky;left:0;z-index:1;min-width:2.6rem}
|
|
948
|
+
.sheettable thead th:first-child{left:0;z-index:3}
|
|
949
|
+
.sheettable td{max-width:18rem}
|
|
950
|
+
|
|
951
|
+
/* ---- responsive / print ---- */
|
|
952
|
+
@media (max-width:1140px){
|
|
953
|
+
.frame{display:block}
|
|
954
|
+
.divider{display:none}
|
|
955
|
+
.pagecol{max-width:44rem;margin:0 auto;padding:2.5rem 1.25rem 4rem}
|
|
956
|
+
.railcol{background:none}
|
|
957
|
+
.rail{position:static;max-height:none;padding:0}
|
|
958
|
+
.resting{display:none}
|
|
959
|
+
.card{position:fixed;left:50%;transform:translateX(-50%);bottom:1rem;z-index:80;
|
|
960
|
+
width:min(30rem,calc(100vw - 2rem));max-height:72vh;overflow-y:auto;resize:none}
|
|
961
|
+
@keyframes rise{from{opacity:0;transform:translate(-50%,.5rem)}
|
|
962
|
+
to{opacity:1;transform:translate(-50%,0)}}
|
|
963
|
+
}
|
|
964
|
+
@media (prefers-reduced-motion:reduce){*{animation:none!important;transition:none!important}}
|
|
965
|
+
@media print{
|
|
966
|
+
.railcol,.divider,.overlay{display:none}
|
|
967
|
+
.frame{display:block}
|
|
968
|
+
.pagecol{max-width:none;padding:0}
|
|
969
|
+
.note{break-inside:avoid}
|
|
970
|
+
.claim{text-decoration:none}
|
|
971
|
+
}
|
|
972
|
+
:focus-visible{outline:2px solid var(--ink);outline-offset:2px}
|
|
973
|
+
"""
|
|
974
|
+
|
|
975
|
+
SCRIPT = """
|
|
976
|
+
(function () {
|
|
977
|
+
var rail = document.querySelector('.rail');
|
|
978
|
+
var resting = document.querySelector('.resting');
|
|
979
|
+
var sheetsEl = document.getElementById('bd-sheets');
|
|
980
|
+
var sheets = sheetsEl ? JSON.parse(sheetsEl.textContent || '{}') : {};
|
|
981
|
+
var active = null, activeCard = null;
|
|
982
|
+
|
|
983
|
+
function fmt(raw) {
|
|
984
|
+
if (raw === '' || raw == null) return '';
|
|
985
|
+
var v = Number(raw);
|
|
986
|
+
if (isNaN(v)) return raw;
|
|
987
|
+
if (v === Math.trunc(v) && Math.abs(v) < 1e15) return v.toLocaleString('en-US');
|
|
988
|
+
if (Math.abs(v) < 1) return v.toFixed(4);
|
|
989
|
+
return Math.round(v).toLocaleString('en-US');
|
|
990
|
+
}
|
|
991
|
+
function colName(n) {
|
|
992
|
+
var s = '';
|
|
993
|
+
while (n > 0) { var r = (n - 1) % 26; s = String.fromCharCode(65 + r) + s; n = (n - 1 - r) / 26; }
|
|
994
|
+
return s;
|
|
995
|
+
}
|
|
996
|
+
|
|
997
|
+
function deactivate() {
|
|
998
|
+
if (activeCard) activeCard.hidden = true;
|
|
999
|
+
if (active) active.classList.remove('active');
|
|
1000
|
+
if (resting) resting.style.display = '';
|
|
1001
|
+
active = activeCard = null;
|
|
1002
|
+
}
|
|
1003
|
+
function activate(n, claim) {
|
|
1004
|
+
var card = document.getElementById('card-' + n);
|
|
1005
|
+
if (!card) return;
|
|
1006
|
+
deactivate();
|
|
1007
|
+
if (resting) resting.style.display = 'none';
|
|
1008
|
+
card.querySelectorAll('img[data-ev]').forEach(function (img) {
|
|
1009
|
+
if (!img.src) {
|
|
1010
|
+
var stored = document.getElementById(img.dataset.ev);
|
|
1011
|
+
if (stored) img.src = stored.src;
|
|
1012
|
+
}
|
|
1013
|
+
});
|
|
1014
|
+
card.hidden = false;
|
|
1015
|
+
if (claim) {
|
|
1016
|
+
claim.classList.add('active');
|
|
1017
|
+
active = claim;
|
|
1018
|
+
}
|
|
1019
|
+
activeCard = card;
|
|
1020
|
+
if (window.matchMedia('(min-width: 1141px)').matches) {
|
|
1021
|
+
rail.scrollTop = 0;
|
|
1022
|
+
if (claim) {
|
|
1023
|
+
var r = claim.getBoundingClientRect();
|
|
1024
|
+
if (r.top < 0 || r.bottom > innerHeight) claim.scrollIntoView({block: 'center'});
|
|
1025
|
+
}
|
|
1026
|
+
}
|
|
1027
|
+
}
|
|
1028
|
+
|
|
1029
|
+
function openSheet(key, cited) {
|
|
1030
|
+
var data = sheets[key];
|
|
1031
|
+
if (!data) return;
|
|
1032
|
+
var box = document.getElementById('sheetoverlay');
|
|
1033
|
+
var scroll = box.querySelector('.sheetscroll');
|
|
1034
|
+
box.querySelector('.sheetname').textContent =
|
|
1035
|
+
data.name.replace(/-/g, ' ').replace(/\\b\\w/g, function (ch) { return ch.toUpperCase(); });
|
|
1036
|
+
var citedCol = cited ? cited.replace(/\\d+$/, '') : null;
|
|
1037
|
+
var citedRow = cited ? cited.replace(/^[A-Z]+/, '') : null;
|
|
1038
|
+
var h = '<table class="sheettable"><thead><tr><th></th>';
|
|
1039
|
+
for (var c = 1; c <= data.ncols; c++) {
|
|
1040
|
+
var cn = colName(c);
|
|
1041
|
+
h += '<th' + (cn === citedCol ? ' class="citedcol"' : '') + '>' + cn + '</th>';
|
|
1042
|
+
}
|
|
1043
|
+
h += '</tr></thead><tbody>';
|
|
1044
|
+
for (var r = 1; r <= data.nrows; r++) {
|
|
1045
|
+
h += '<tr' + (String(r) === citedRow ? ' class="citedrow"' : '') + '><th>' + r + '</th>';
|
|
1046
|
+
for (var c2 = 1; c2 <= data.ncols; c2++) {
|
|
1047
|
+
var raw = data.rows[r - 1][c2 - 1];
|
|
1048
|
+
var ref = colName(c2) + r;
|
|
1049
|
+
var cls = [];
|
|
1050
|
+
if (ref === cited) cls.push('cited');
|
|
1051
|
+
if (raw !== '' && raw != null && isFinite(Number(raw))) cls.push('num');
|
|
1052
|
+
h += '<td' + (cls.length ? ' class="' + cls.join(' ') + '"' : '') +
|
|
1053
|
+
' title="' + String(raw).replace(/"/g, '"') + '">' + fmt(raw) + '</td>';
|
|
1054
|
+
}
|
|
1055
|
+
h += '</tr>';
|
|
1056
|
+
}
|
|
1057
|
+
h += '</tbody></table>';
|
|
1058
|
+
scroll.innerHTML = h;
|
|
1059
|
+
box.classList.add('open');
|
|
1060
|
+
var hlCell = scroll.querySelector('td.cited');
|
|
1061
|
+
if (hlCell) {
|
|
1062
|
+
scroll.scrollTop = hlCell.offsetTop - scroll.clientHeight / 2;
|
|
1063
|
+
scroll.scrollLeft = hlCell.offsetLeft - scroll.clientWidth / 2;
|
|
1064
|
+
}
|
|
1065
|
+
}
|
|
1066
|
+
|
|
1067
|
+
document.addEventListener('click', function (e) {
|
|
1068
|
+
var claim = e.target.closest('a.claim');
|
|
1069
|
+
if (claim) {
|
|
1070
|
+
e.preventDefault();
|
|
1071
|
+
if (active === claim) deactivate();
|
|
1072
|
+
else activate(claim.dataset.claim, claim);
|
|
1073
|
+
return;
|
|
1074
|
+
}
|
|
1075
|
+
if (e.target.closest('[data-close]')) {
|
|
1076
|
+
var over = e.target.closest('.overlay');
|
|
1077
|
+
if (over) { over.classList.remove('open'); return; }
|
|
1078
|
+
deactivate(); return;
|
|
1079
|
+
}
|
|
1080
|
+
var sel = e.target.closest('.srcsel button');
|
|
1081
|
+
if (sel) {
|
|
1082
|
+
var box = sel.closest('.card');
|
|
1083
|
+
box.querySelectorAll('.srcsel button').forEach(function (b) { b.classList.remove('on'); });
|
|
1084
|
+
box.querySelectorAll('.cite').forEach(function (ct) { ct.classList.remove('on'); });
|
|
1085
|
+
sel.classList.add('on');
|
|
1086
|
+
box.querySelector('.cite[data-cite="' + sel.dataset.cite + '"]').classList.add('on');
|
|
1087
|
+
return;
|
|
1088
|
+
}
|
|
1089
|
+
var tab = e.target.closest('.tab');
|
|
1090
|
+
if (tab) {
|
|
1091
|
+
var evd = tab.closest('.evidence');
|
|
1092
|
+
evd.querySelectorAll('.tab').forEach(function (t) { t.classList.remove('on'); });
|
|
1093
|
+
evd.querySelectorAll('.pane').forEach(function (p) { p.classList.remove('on'); });
|
|
1094
|
+
tab.classList.add('on');
|
|
1095
|
+
evd.querySelector('.pane[data-pane="' + tab.dataset.pane + '"]').classList.add('on');
|
|
1096
|
+
return;
|
|
1097
|
+
}
|
|
1098
|
+
var noteRow = e.target.closest('.note');
|
|
1099
|
+
if (noteRow && !e.target.closest('a') && !e.target.closest('summary')) {
|
|
1100
|
+
var noteN = noteRow.id.replace('note-', '');
|
|
1101
|
+
var noteClaim = document.getElementById('claim-' + noteN);
|
|
1102
|
+
if (noteClaim) {
|
|
1103
|
+
noteClaim.scrollIntoView({block: 'center'});
|
|
1104
|
+
activate(noteN, noteClaim);
|
|
1105
|
+
}
|
|
1106
|
+
return;
|
|
1107
|
+
}
|
|
1108
|
+
var grid = e.target.closest('.grid[data-sheet]');
|
|
1109
|
+
if (grid) { openSheet(grid.dataset.sheet, grid.dataset.cited); return; }
|
|
1110
|
+
var img = e.target.closest('.plate img');
|
|
1111
|
+
if (img && img.src) {
|
|
1112
|
+
var zoom = document.getElementById('zoom');
|
|
1113
|
+
zoom.querySelector('img').src = img.src;
|
|
1114
|
+
zoom.classList.add('open');
|
|
1115
|
+
return;
|
|
1116
|
+
}
|
|
1117
|
+
var overlay = e.target.closest('.overlay');
|
|
1118
|
+
if (overlay && (e.target === overlay || overlay.id === 'zoom')) {
|
|
1119
|
+
overlay.classList.remove('open');
|
|
1120
|
+
}
|
|
1121
|
+
});
|
|
1122
|
+
document.addEventListener('keydown', function (e) {
|
|
1123
|
+
if (e.key !== 'Escape') return;
|
|
1124
|
+
var open = document.querySelector('.overlay.open');
|
|
1125
|
+
if (open) open.classList.remove('open');
|
|
1126
|
+
else deactivate();
|
|
1127
|
+
});
|
|
1128
|
+
|
|
1129
|
+
/* draggable divider */
|
|
1130
|
+
var divider = document.querySelector('.divider');
|
|
1131
|
+
if (divider) {
|
|
1132
|
+
var startX = 0, startW = 0;
|
|
1133
|
+
divider.addEventListener('pointerdown', function (e) {
|
|
1134
|
+
startX = e.clientX;
|
|
1135
|
+
startW = document.querySelector('.railcol').getBoundingClientRect().width;
|
|
1136
|
+
divider.classList.add('dragging');
|
|
1137
|
+
divider.setPointerCapture(e.pointerId);
|
|
1138
|
+
});
|
|
1139
|
+
divider.addEventListener('pointermove', function (e) {
|
|
1140
|
+
if (!divider.classList.contains('dragging')) return;
|
|
1141
|
+
var w = Math.min(Math.max(startW - (e.clientX - startX), 320), innerWidth - 420);
|
|
1142
|
+
document.documentElement.style.setProperty('--rail-w', w + 'px');
|
|
1143
|
+
});
|
|
1144
|
+
['pointerup', 'pointercancel'].forEach(function (ev) {
|
|
1145
|
+
divider.addEventListener(ev, function () { divider.classList.remove('dragging'); });
|
|
1146
|
+
});
|
|
1147
|
+
}
|
|
1148
|
+
})();
|
|
1149
|
+
"""
|
|
1150
|
+
|
|
1151
|
+
PAGE = """<!doctype html>
|
|
1152
|
+
<html lang="en">
|
|
1153
|
+
<head>
|
|
1154
|
+
<meta charset="utf-8">
|
|
1155
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
1156
|
+
<meta http-equiv="Content-Security-Policy"
|
|
1157
|
+
content="default-src 'none'; style-src 'unsafe-inline'; script-src 'unsafe-inline'; img-src data:;">
|
|
1158
|
+
<meta name="generator" content="{format}">
|
|
1159
|
+
<link rel="icon" href="{favicon}">
|
|
1160
|
+
<title>{title}</title>
|
|
1161
|
+
<style>{css}</style>
|
|
1162
|
+
</head>
|
|
1163
|
+
<body>
|
|
1164
|
+
<div class="frame">
|
|
1165
|
+
<div class="pagecol">
|
|
1166
|
+
<header class="masthead">
|
|
1167
|
+
<h1>{title}</h1>
|
|
1168
|
+
{subtitle}{alarm}
|
|
1169
|
+
</header>
|
|
1170
|
+
<main class="doc">
|
|
1171
|
+
{body}
|
|
1172
|
+
</main>
|
|
1173
|
+
<section class="endmatter">
|
|
1174
|
+
<h2>Sources</h2>
|
|
1175
|
+
<ul class="srclist">{sources}</ul>
|
|
1176
|
+
<h2>Notes</h2>
|
|
1177
|
+
<ol class="notes">
|
|
1178
|
+
{notes}
|
|
1179
|
+
</ol>
|
|
1180
|
+
<p class="colophon">{mark_svg} Generated by Backdraft</p>
|
|
1181
|
+
</section>
|
|
1182
|
+
</div>
|
|
1183
|
+
<div class="divider" role="separator" aria-orientation="vertical"
|
|
1184
|
+
aria-label="Resize the evidence rail" title="Drag to resize"></div>
|
|
1185
|
+
<aside class="railcol">
|
|
1186
|
+
<div class="rail">
|
|
1187
|
+
<div class="resting">
|
|
1188
|
+
<h2>Sources</h2>
|
|
1189
|
+
<ul>{sources}</ul>
|
|
1190
|
+
<p class="hint">Click any numbered claim to see the source behind it.</p>
|
|
1191
|
+
</div>
|
|
1192
|
+
{cards}
|
|
1193
|
+
</div>
|
|
1194
|
+
</aside>
|
|
1195
|
+
</div>
|
|
1196
|
+
<div class="overlay" id="zoom" role="dialog" aria-label="Enlarged page"><img alt=""></div>
|
|
1197
|
+
<div class="overlay" id="sheetoverlay" role="dialog" aria-label="Full sheet">
|
|
1198
|
+
<div class="sheetbox"><header><span class="sheetname"></span>
|
|
1199
|
+
<button class="close" data-close aria-label="Close">×</button></header>
|
|
1200
|
+
<div class="sheetscroll"></div></div></div>
|
|
1201
|
+
{store}
|
|
1202
|
+
<script type="application/json" id="{sheets_island_id}">
|
|
1203
|
+
{sheets}
|
|
1204
|
+
</script>
|
|
1205
|
+
<script type="application/json" id="{island_id}">
|
|
1206
|
+
{island}
|
|
1207
|
+
</script>
|
|
1208
|
+
<script>{js}</script>
|
|
1209
|
+
</body>
|
|
1210
|
+
</html>
|
|
1211
|
+
"""
|