@rubytech/create-maxy-code 0.1.60 → 0.1.62
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.
- package/package.json +1 -1
- package/payload/platform/plugins/admin/PLUGIN.md +1 -1
- package/payload/platform/plugins/brochures/skills/property-brochure/SKILL.md +26 -3
- package/payload/platform/plugins/brochures/skills/property-brochure/references/registers.md +1 -1
- package/payload/platform/plugins/docs/references/admin-session.md +2 -2
- package/payload/platform/plugins/docs/references/internals.md +1 -1
- package/payload/platform/plugins/docs/references/plugins-guide.md +1 -1
- package/payload/platform/plugins/epc/.claude-plugin/plugin.json +17 -0
- package/payload/platform/plugins/epc/PLUGIN.md +85 -0
- package/payload/platform/plugins/epc/mcp/dist/index.d.ts +2 -0
- package/payload/platform/plugins/epc/mcp/dist/index.d.ts.map +1 -0
- package/payload/platform/plugins/epc/mcp/dist/index.js +117 -0
- package/payload/platform/plugins/epc/mcp/dist/index.js.map +1 -0
- package/payload/platform/plugins/epc/mcp/dist/lib/crypto.d.ts +3 -0
- package/payload/platform/plugins/epc/mcp/dist/lib/crypto.d.ts.map +1 -0
- package/payload/platform/plugins/epc/mcp/dist/lib/crypto.js +72 -0
- package/payload/platform/plugins/epc/mcp/dist/lib/crypto.js.map +1 -0
- package/payload/platform/plugins/epc/mcp/dist/lib/epc-api.d.ts +60 -0
- package/payload/platform/plugins/epc/mcp/dist/lib/epc-api.d.ts.map +1 -0
- package/payload/platform/plugins/epc/mcp/dist/lib/epc-api.js +181 -0
- package/payload/platform/plugins/epc/mcp/dist/lib/epc-api.js.map +1 -0
- package/payload/platform/plugins/epc/mcp/dist/lib/file-crypto.d.ts +3 -0
- package/payload/platform/plugins/epc/mcp/dist/lib/file-crypto.d.ts.map +1 -0
- package/payload/platform/plugins/epc/mcp/dist/lib/file-crypto.js +49 -0
- package/payload/platform/plugins/epc/mcp/dist/lib/file-crypto.js.map +1 -0
- package/payload/platform/plugins/epc/mcp/dist/lib/key-store.d.ts +15 -0
- package/payload/platform/plugins/epc/mcp/dist/lib/key-store.d.ts.map +1 -0
- package/payload/platform/plugins/epc/mcp/dist/lib/key-store.js +130 -0
- package/payload/platform/plugins/epc/mcp/dist/lib/key-store.js.map +1 -0
- package/payload/platform/plugins/epc/mcp/dist/lib/neo4j.d.ts +5 -0
- package/payload/platform/plugins/epc/mcp/dist/lib/neo4j.d.ts.map +1 -0
- package/payload/platform/plugins/epc/mcp/dist/lib/neo4j.js +38 -0
- package/payload/platform/plugins/epc/mcp/dist/lib/neo4j.js.map +1 -0
- package/payload/platform/plugins/epc/mcp/dist/tools/key-deregister.d.ts +4 -0
- package/payload/platform/plugins/epc/mcp/dist/tools/key-deregister.d.ts.map +1 -0
- package/payload/platform/plugins/epc/mcp/dist/tools/key-deregister.js +9 -0
- package/payload/platform/plugins/epc/mcp/dist/tools/key-deregister.js.map +1 -0
- package/payload/platform/plugins/epc/mcp/dist/tools/key-list.d.ts +4 -0
- package/payload/platform/plugins/epc/mcp/dist/tools/key-list.d.ts.map +1 -0
- package/payload/platform/plugins/epc/mcp/dist/tools/key-list.js +10 -0
- package/payload/platform/plugins/epc/mcp/dist/tools/key-list.js.map +1 -0
- package/payload/platform/plugins/epc/mcp/dist/tools/key-register.d.ts +5 -0
- package/payload/platform/plugins/epc/mcp/dist/tools/key-register.d.ts.map +1 -0
- package/payload/platform/plugins/epc/mcp/dist/tools/key-register.js +36 -0
- package/payload/platform/plugins/epc/mcp/dist/tools/key-register.js.map +1 -0
- package/payload/platform/plugins/epc/mcp/dist/tools/lookup.d.ts +10 -0
- package/payload/platform/plugins/epc/mcp/dist/tools/lookup.d.ts.map +1 -0
- package/payload/platform/plugins/epc/mcp/dist/tools/lookup.js +30 -0
- package/payload/platform/plugins/epc/mcp/dist/tools/lookup.js.map +1 -0
- package/payload/platform/plugins/epc/mcp/package-lock.json +2566 -0
- package/payload/platform/plugins/epc/mcp/package.json +21 -0
- package/payload/platform/plugins/epc/mcp/src/__tests__/epc-api.test.ts +251 -0
- package/payload/platform/plugins/epc/mcp/src/__tests__/key-roundtrip.test.ts +113 -0
- package/payload/platform/plugins/epc/mcp/src/__tests__/lookup.test.ts +181 -0
- package/payload/platform/plugins/epc/mcp/src/__tests__/schema-parity.test.ts +54 -0
- package/payload/platform/plugins/epc/mcp/src/index.ts +156 -0
- package/payload/platform/plugins/epc/mcp/src/lib/crypto.ts +79 -0
- package/payload/platform/plugins/epc/mcp/src/lib/epc-api.ts +241 -0
- package/payload/platform/plugins/epc/mcp/src/lib/file-crypto.ts +55 -0
- package/payload/platform/plugins/epc/mcp/src/lib/key-store.ts +172 -0
- package/payload/platform/plugins/epc/mcp/src/lib/neo4j.ts +47 -0
- package/payload/platform/plugins/epc/mcp/src/tools/key-deregister.ts +9 -0
- package/payload/platform/plugins/epc/mcp/src/tools/key-list.ts +10 -0
- package/payload/platform/plugins/epc/mcp/src/tools/key-register.ts +44 -0
- package/payload/platform/plugins/epc/mcp/src/tools/lookup.ts +43 -0
- package/payload/platform/plugins/epc/mcp/tsconfig.json +20 -0
- package/payload/platform/plugins/epc/mcp/vitest.config.ts +8 -0
- package/payload/platform/plugins/preval/.claude-plugin/plugin.json +1 -1
- package/payload/platform/plugins/preval/PLUGIN.md +25 -17
- package/payload/platform/plugins/preval/skills/property-preval/SKILL.md +107 -39
- package/payload/platform/plugins/preval/skills/property-preval/references/render.py +449 -84
- package/payload/platform/plugins/preval/skills/property-preval/references/template-inputs.schema.json +126 -80
- package/payload/platform/plugins/preval/skills/property-preval/references/template.html +183 -124
- package/payload/platform/services/claude-session-manager/dist/http-server.d.ts.map +1 -1
- package/payload/platform/services/claude-session-manager/dist/http-server.js +6 -1
- package/payload/platform/services/claude-session-manager/dist/http-server.js.map +1 -1
- package/payload/platform/services/claude-session-manager/dist/pty-spawner.d.ts +7 -0
- package/payload/platform/services/claude-session-manager/dist/pty-spawner.d.ts.map +1 -1
- package/payload/platform/services/claude-session-manager/dist/pty-spawner.js +8 -0
- package/payload/platform/services/claude-session-manager/dist/pty-spawner.js.map +1 -1
- package/payload/platform/templates/agents/admin/IDENTITY.md +1 -1
- package/payload/platform/templates/agents/admin/SOUL.md +1 -1
- package/payload/premium-plugins/real-agent/BUNDLE.md +4 -2
- package/payload/premium-plugins/real-agent/agents/valuer.md +1 -1
- package/payload/premium-plugins/real-agent/plugins/.claude-plugin/marketplace.json +5 -0
- package/payload/premium-plugins/real-agent/plugins/brochures/skills/property-brochure/SKILL.md +26 -3
- package/payload/premium-plugins/real-agent/plugins/brochures/skills/property-brochure/references/registers.md +1 -1
- package/payload/premium-plugins/real-agent/plugins/epc/.claude-plugin/plugin.json +17 -0
- package/payload/premium-plugins/real-agent/plugins/epc/PLUGIN.md +85 -0
- package/payload/premium-plugins/real-agent/plugins/epc/mcp/dist/index.d.ts +2 -0
- package/payload/premium-plugins/real-agent/plugins/epc/mcp/dist/index.d.ts.map +1 -0
- package/payload/premium-plugins/real-agent/plugins/epc/mcp/dist/index.js +117 -0
- package/payload/premium-plugins/real-agent/plugins/epc/mcp/dist/index.js.map +1 -0
- package/payload/premium-plugins/real-agent/plugins/epc/mcp/dist/lib/crypto.d.ts +3 -0
- package/payload/premium-plugins/real-agent/plugins/epc/mcp/dist/lib/crypto.d.ts.map +1 -0
- package/payload/premium-plugins/real-agent/plugins/epc/mcp/dist/lib/crypto.js +72 -0
- package/payload/premium-plugins/real-agent/plugins/epc/mcp/dist/lib/crypto.js.map +1 -0
- package/payload/premium-plugins/real-agent/plugins/epc/mcp/dist/lib/epc-api.d.ts +60 -0
- package/payload/premium-plugins/real-agent/plugins/epc/mcp/dist/lib/epc-api.d.ts.map +1 -0
- package/payload/premium-plugins/real-agent/plugins/epc/mcp/dist/lib/epc-api.js +181 -0
- package/payload/premium-plugins/real-agent/plugins/epc/mcp/dist/lib/epc-api.js.map +1 -0
- package/payload/premium-plugins/real-agent/plugins/epc/mcp/dist/lib/file-crypto.d.ts +3 -0
- package/payload/premium-plugins/real-agent/plugins/epc/mcp/dist/lib/file-crypto.d.ts.map +1 -0
- package/payload/premium-plugins/real-agent/plugins/epc/mcp/dist/lib/file-crypto.js +49 -0
- package/payload/premium-plugins/real-agent/plugins/epc/mcp/dist/lib/file-crypto.js.map +1 -0
- package/payload/premium-plugins/real-agent/plugins/epc/mcp/dist/lib/key-store.d.ts +15 -0
- package/payload/premium-plugins/real-agent/plugins/epc/mcp/dist/lib/key-store.d.ts.map +1 -0
- package/payload/premium-plugins/real-agent/plugins/epc/mcp/dist/lib/key-store.js +130 -0
- package/payload/premium-plugins/real-agent/plugins/epc/mcp/dist/lib/key-store.js.map +1 -0
- package/payload/premium-plugins/real-agent/plugins/epc/mcp/dist/lib/neo4j.d.ts +5 -0
- package/payload/premium-plugins/real-agent/plugins/epc/mcp/dist/lib/neo4j.d.ts.map +1 -0
- package/payload/premium-plugins/real-agent/plugins/epc/mcp/dist/lib/neo4j.js +38 -0
- package/payload/premium-plugins/real-agent/plugins/epc/mcp/dist/lib/neo4j.js.map +1 -0
- package/payload/premium-plugins/real-agent/plugins/epc/mcp/dist/tools/key-deregister.d.ts +4 -0
- package/payload/premium-plugins/real-agent/plugins/epc/mcp/dist/tools/key-deregister.d.ts.map +1 -0
- package/payload/premium-plugins/real-agent/plugins/epc/mcp/dist/tools/key-deregister.js +9 -0
- package/payload/premium-plugins/real-agent/plugins/epc/mcp/dist/tools/key-deregister.js.map +1 -0
- package/payload/premium-plugins/real-agent/plugins/epc/mcp/dist/tools/key-list.d.ts +4 -0
- package/payload/premium-plugins/real-agent/plugins/epc/mcp/dist/tools/key-list.d.ts.map +1 -0
- package/payload/premium-plugins/real-agent/plugins/epc/mcp/dist/tools/key-list.js +10 -0
- package/payload/premium-plugins/real-agent/plugins/epc/mcp/dist/tools/key-list.js.map +1 -0
- package/payload/premium-plugins/real-agent/plugins/epc/mcp/dist/tools/key-register.d.ts +5 -0
- package/payload/premium-plugins/real-agent/plugins/epc/mcp/dist/tools/key-register.d.ts.map +1 -0
- package/payload/premium-plugins/real-agent/plugins/epc/mcp/dist/tools/key-register.js +36 -0
- package/payload/premium-plugins/real-agent/plugins/epc/mcp/dist/tools/key-register.js.map +1 -0
- package/payload/premium-plugins/real-agent/plugins/epc/mcp/dist/tools/lookup.d.ts +10 -0
- package/payload/premium-plugins/real-agent/plugins/epc/mcp/dist/tools/lookup.d.ts.map +1 -0
- package/payload/premium-plugins/real-agent/plugins/epc/mcp/dist/tools/lookup.js +30 -0
- package/payload/premium-plugins/real-agent/plugins/epc/mcp/dist/tools/lookup.js.map +1 -0
- package/payload/premium-plugins/real-agent/plugins/epc/mcp/package-lock.json +2566 -0
- package/payload/premium-plugins/real-agent/plugins/epc/mcp/package.json +21 -0
- package/payload/premium-plugins/real-agent/plugins/epc/mcp/src/__tests__/epc-api.test.ts +251 -0
- package/payload/premium-plugins/real-agent/plugins/epc/mcp/src/__tests__/key-roundtrip.test.ts +113 -0
- package/payload/premium-plugins/real-agent/plugins/epc/mcp/src/__tests__/lookup.test.ts +181 -0
- package/payload/premium-plugins/real-agent/plugins/epc/mcp/src/__tests__/schema-parity.test.ts +54 -0
- package/payload/premium-plugins/real-agent/plugins/epc/mcp/src/index.ts +156 -0
- package/payload/premium-plugins/real-agent/plugins/epc/mcp/src/lib/crypto.ts +79 -0
- package/payload/premium-plugins/real-agent/plugins/epc/mcp/src/lib/epc-api.ts +241 -0
- package/payload/premium-plugins/real-agent/plugins/epc/mcp/src/lib/file-crypto.ts +55 -0
- package/payload/premium-plugins/real-agent/plugins/epc/mcp/src/lib/key-store.ts +172 -0
- package/payload/premium-plugins/real-agent/plugins/epc/mcp/src/lib/neo4j.ts +47 -0
- package/payload/premium-plugins/real-agent/plugins/epc/mcp/src/tools/key-deregister.ts +9 -0
- package/payload/premium-plugins/real-agent/plugins/epc/mcp/src/tools/key-list.ts +10 -0
- package/payload/premium-plugins/real-agent/plugins/epc/mcp/src/tools/key-register.ts +44 -0
- package/payload/premium-plugins/real-agent/plugins/epc/mcp/src/tools/lookup.ts +43 -0
- package/payload/premium-plugins/real-agent/plugins/epc/mcp/tsconfig.json +20 -0
- package/payload/premium-plugins/real-agent/plugins/epc/mcp/vitest.config.ts +8 -0
- package/payload/premium-plugins/real-agent/plugins/preval/.claude-plugin/plugin.json +1 -1
- package/payload/premium-plugins/real-agent/plugins/preval/PLUGIN.md +25 -17
- package/payload/premium-plugins/real-agent/plugins/preval/skills/property-preval/SKILL.md +107 -39
- package/payload/premium-plugins/real-agent/plugins/preval/skills/property-preval/references/render.py +449 -84
- package/payload/premium-plugins/real-agent/plugins/preval/skills/property-preval/references/template-inputs.schema.json +126 -80
- package/payload/premium-plugins/real-agent/plugins/preval/skills/property-preval/references/template.html +183 -124
- package/payload/server/public/assets/{admin-Bp-BjBCX.js → admin-DhN3G8W7.js} +1 -1
- package/payload/server/public/assets/{data-BGUAGVkV.js → data-B2ZVXOcE.js} +1 -1
- package/payload/server/public/assets/{graph-MvYxZOBF.js → graph-BAMGPHrK.js} +1 -1
- package/payload/server/public/assets/{graph-labels-D865qb3K.js → graph-labels-D9eBbvxo.js} +1 -1
- package/payload/server/public/assets/{page-C2b1nlOc.js → page-CV27Al6Z.js} +1 -1
- package/payload/server/public/assets/{page--hOVRrgN.js → page-DjdVMWCz.js} +1 -1
- package/payload/server/public/data.html +3 -3
- package/payload/server/public/graph.html +3 -3
- package/payload/server/public/index.html +4 -4
- package/payload/server/server.js +3 -16
|
@@ -2,20 +2,38 @@
|
|
|
2
2
|
|
|
3
3
|
Substitutes <!-- REPLACE: <slot> --> placeholders in template.html with values
|
|
4
4
|
derived from one inputs.json (shape: template-inputs.schema.json), drives
|
|
5
|
-
Chrome headless to produce a
|
|
5
|
+
Chrome headless to produce a 5-page A4 PDF, and asserts page count == 5.
|
|
6
|
+
|
|
7
|
+
Fails loud on any missing / unverifiable load-bearing input. No silent
|
|
8
|
+
fallback modes — see _assert_inputs.
|
|
6
9
|
|
|
7
10
|
Usage:
|
|
8
11
|
python3 render.py inputs.json
|
|
9
12
|
"""
|
|
10
13
|
from __future__ import annotations
|
|
11
|
-
import html, json, re, shutil, subprocess, sys
|
|
14
|
+
import html, json, re, shutil, subprocess, sys, time
|
|
12
15
|
from pathlib import Path
|
|
13
16
|
|
|
14
17
|
|
|
18
|
+
M2_TO_SQFT = 10.7639
|
|
19
|
+
|
|
20
|
+
|
|
15
21
|
def _money(n: int) -> str:
|
|
16
22
|
return f"£{int(n):,}"
|
|
17
23
|
|
|
18
24
|
|
|
25
|
+
def _range_money(low: int, high: int) -> str:
|
|
26
|
+
return f"£{int(low):,} – £{int(high):,}"
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
# ---------------- helpers ----------------
|
|
30
|
+
|
|
31
|
+
def _short_address(address: str) -> str:
|
|
32
|
+
"""First two address tokens for the cover meta strip."""
|
|
33
|
+
parts = [p.strip() for p in address.split(",")]
|
|
34
|
+
return ", ".join(parts[:2]) if len(parts) >= 2 else address
|
|
35
|
+
|
|
36
|
+
|
|
19
37
|
def _sold_comps_rows(rows: list[dict]) -> str:
|
|
20
38
|
if not rows:
|
|
21
39
|
return '<tr><td colspan="4" class="muted">No sold transactions in the sample.</td></tr>'
|
|
@@ -49,31 +67,6 @@ def _asking_comps_tiles(rows: list[dict]) -> str:
|
|
|
49
67
|
return "".join(out)
|
|
50
68
|
|
|
51
69
|
|
|
52
|
-
def _crime_rows(rows: list[dict]) -> str:
|
|
53
|
-
if not rows:
|
|
54
|
-
return '<div class="muted">No crime data.</div>'
|
|
55
|
-
top = sorted(rows, key=lambda r: r["count"], reverse=True)[:6]
|
|
56
|
-
cap = max((r["count"] for r in top), default=1) or 1
|
|
57
|
-
return "".join(
|
|
58
|
-
f'<div class="dist-row">'
|
|
59
|
-
f'<div class="name">{html.escape(r["category"])}</div>'
|
|
60
|
-
f'<div class="bar"><span{" class=gold" if i == 0 else ""} style="width:{int(100 * r["count"] / cap)}%"></span></div>'
|
|
61
|
-
f'<div class="pct">{r["count"]}</div>'
|
|
62
|
-
f"</div>"
|
|
63
|
-
for i, r in enumerate(top)
|
|
64
|
-
)
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
def _band_rows(rows: list[dict]) -> str:
|
|
68
|
-
if not rows:
|
|
69
|
-
return '<tr><td colspan="2" class="muted">No banding data.</td></tr>'
|
|
70
|
-
return "".join(
|
|
71
|
-
f"<tr><td>Band {html.escape(r['band'])}</td>"
|
|
72
|
-
f"<td class=\"num\">{_money(r['amount'])}</td></tr>"
|
|
73
|
-
for r in rows
|
|
74
|
-
)
|
|
75
|
-
|
|
76
|
-
|
|
77
70
|
def _type_dist_rows(rows: list[dict]) -> str:
|
|
78
71
|
if not rows:
|
|
79
72
|
return '<div class="muted">No property-type mix data.</div>'
|
|
@@ -89,7 +82,7 @@ def _type_dist_rows(rows: list[dict]) -> str:
|
|
|
89
82
|
|
|
90
83
|
|
|
91
84
|
def _spark_svg(price_series: list[dict], psf_series: list[dict]) -> str:
|
|
92
|
-
"""Two-line sparkline in a 1000x380 viewBox SVG, no external
|
|
85
|
+
"""Two-line sparkline in a 1000x380 viewBox SVG, no external deps."""
|
|
93
86
|
W, H = 1000, 380
|
|
94
87
|
pad_l, pad_r, pad_t, pad_b = 70, 30, 30, 50
|
|
95
88
|
iw, ih = W - pad_l - pad_r, H - pad_t - pad_b
|
|
@@ -102,9 +95,7 @@ def _spark_svg(price_series: list[dict], psf_series: list[dict]) -> str:
|
|
|
102
95
|
if ymax == ymin:
|
|
103
96
|
ymax = ymin + 1
|
|
104
97
|
n = len(series)
|
|
105
|
-
pts = []
|
|
106
|
-
circles = []
|
|
107
|
-
labels = []
|
|
98
|
+
pts, circles, labels = [], [], []
|
|
108
99
|
for i, p in enumerate(series):
|
|
109
100
|
x = pad_l + (i * iw / max(1, n - 1))
|
|
110
101
|
y = pad_t + ih - ((float(p[key]) - ymin) / (ymax - ymin)) * ih
|
|
@@ -115,8 +106,7 @@ def _spark_svg(price_series: list[dict], psf_series: list[dict]) -> str:
|
|
|
115
106
|
)
|
|
116
107
|
return (
|
|
117
108
|
f'<polyline class="{css_pl}" points="{" ".join(pts)}"/>'
|
|
118
|
-
+ "".join(circles)
|
|
119
|
-
+ "".join(labels)
|
|
109
|
+
+ "".join(circles) + "".join(labels)
|
|
120
110
|
)
|
|
121
111
|
|
|
122
112
|
axis = (
|
|
@@ -128,54 +118,406 @@ def _spark_svg(price_series: list[dict], psf_series: list[dict]) -> str:
|
|
|
128
118
|
)
|
|
129
119
|
|
|
130
120
|
|
|
121
|
+
def _summary_paragraphs(paras: list[str]) -> str:
|
|
122
|
+
if not paras:
|
|
123
|
+
return ""
|
|
124
|
+
out = [f'<p class="dropcap">{html.escape(paras[0])}</p>']
|
|
125
|
+
for p in paras[1:]:
|
|
126
|
+
out.append(f"<p>{html.escape(p)}</p>")
|
|
127
|
+
return "".join(out)
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def _wordmark(brand: dict, variant: str, out_dir: Path) -> str:
|
|
131
|
+
"""variant ∈ {'light', 'dark'}. Mirrors property-market-report's wordmark."""
|
|
132
|
+
key = "logo_light" if variant == "light" else "logo_dark"
|
|
133
|
+
logo_path = brand.get(key)
|
|
134
|
+
if logo_path:
|
|
135
|
+
src = Path(logo_path).expanduser()
|
|
136
|
+
dest_dir = out_dir / "img"
|
|
137
|
+
dest_dir.mkdir(parents=True, exist_ok=True)
|
|
138
|
+
dest = dest_dir / src.name
|
|
139
|
+
if src.resolve() != dest.resolve():
|
|
140
|
+
shutil.copy2(src, dest)
|
|
141
|
+
rel = f"img/{src.name}"
|
|
142
|
+
height = "14mm" if variant == "light" else "9mm"
|
|
143
|
+
return f'<img src="{html.escape(rel)}" alt="{html.escape(brand["name"])}" style="height:{height};width:auto;display:block">'
|
|
144
|
+
cls = "wm light" if variant == "light" else "wm small"
|
|
145
|
+
return (
|
|
146
|
+
f'<div class="{cls}">'
|
|
147
|
+
f'<span class="wm-name">{html.escape(brand["name"])}</span>'
|
|
148
|
+
f'<span class="wm-sub">{html.escape(brand["tagline"])}</span>'
|
|
149
|
+
f"</div>"
|
|
150
|
+
)
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def _hero_asset(subject: dict, out_dir: Path) -> str:
|
|
154
|
+
"""Copy subject.cover_hero into img/ and return the relative path. URLs pass through."""
|
|
155
|
+
hero = subject["cover_hero"]
|
|
156
|
+
if hero.startswith(("http://", "https://", "data:")):
|
|
157
|
+
return hero
|
|
158
|
+
src = Path(hero).expanduser()
|
|
159
|
+
if not src.exists():
|
|
160
|
+
raise FileNotFoundError(f"subject.cover_hero not found: {src}")
|
|
161
|
+
dest_dir = out_dir / "img"
|
|
162
|
+
dest_dir.mkdir(parents=True, exist_ok=True)
|
|
163
|
+
dest = dest_dir / f"hero{src.suffix.lower()}"
|
|
164
|
+
if src.resolve() != dest.resolve():
|
|
165
|
+
shutil.copy2(src, dest)
|
|
166
|
+
return f"img/{dest.name}"
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def _listing_card(p: dict, index: int, out_dir: Path) -> str:
|
|
170
|
+
addr_parts = [x.strip() for x in p["address"].split(",")]
|
|
171
|
+
addr = html.escape(addr_parts[0])
|
|
172
|
+
chips = []
|
|
173
|
+
if p.get("beds"): chips.append(f"{p['beds']} bed")
|
|
174
|
+
if p.get("type"): chips.append(html.escape(p["type"]))
|
|
175
|
+
chips_html = "".join(f"<span>{c}</span>" for c in chips)
|
|
176
|
+
img_src = ""
|
|
177
|
+
if p.get("image"):
|
|
178
|
+
src = Path(p["image"]).expanduser() if not str(p["image"]).startswith(("http://", "https://", "data:")) else None
|
|
179
|
+
if src and src.exists():
|
|
180
|
+
dest_dir = out_dir / "img"
|
|
181
|
+
dest_dir.mkdir(parents=True, exist_ok=True)
|
|
182
|
+
dest = dest_dir / f"agent-{index:02d}-{p['slug']}{src.suffix.lower()}"
|
|
183
|
+
if src.resolve() != dest.resolve():
|
|
184
|
+
shutil.copy2(src, dest)
|
|
185
|
+
img_src = f"img/{dest.name}"
|
|
186
|
+
elif src is None:
|
|
187
|
+
img_src = str(p["image"])
|
|
188
|
+
status = p.get("status", "")
|
|
189
|
+
badge_cls = {"Sold": "sold", "Under offer": "under"}.get(status, "")
|
|
190
|
+
photo = (
|
|
191
|
+
f'<div class="photo">'
|
|
192
|
+
f'{f"<img src=\"{html.escape(img_src)}\" alt=\"{addr}\">" if img_src else ""}'
|
|
193
|
+
f'<div class="badge {badge_cls}">{html.escape(status)}</div>'
|
|
194
|
+
f"</div>"
|
|
195
|
+
)
|
|
196
|
+
meta = (
|
|
197
|
+
f'<div class="meta">'
|
|
198
|
+
f'<div class="addr">{addr}</div>'
|
|
199
|
+
f'<div class="price">{_money(p["price"])}</div>'
|
|
200
|
+
f'<div class="kfs">{chips_html}</div>'
|
|
201
|
+
f"</div>"
|
|
202
|
+
)
|
|
203
|
+
inner = photo + meta
|
|
204
|
+
if p.get("url"):
|
|
205
|
+
return f'<a class="listing" href="{html.escape(p["url"])}" target="_blank" rel="noopener">{inner}</a>'
|
|
206
|
+
return f'<div class="listing">{inner}</div>'
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
def _agent_listings_cards(rows: list[dict], out_dir: Path) -> str:
|
|
210
|
+
if not rows:
|
|
211
|
+
return '<div class="muted" style="margin-top:5mm">No recent listings supplied.</div>'
|
|
212
|
+
return "".join(_listing_card(p, i, out_dir) for i, p in enumerate(rows[:6]))
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
# ---------------- valuation maths ----------------
|
|
216
|
+
|
|
217
|
+
def _valuation_block(subject: dict, valuation: dict) -> str:
|
|
218
|
+
"""Build the page-2 valuation panel: headline range, adjustments, final range.
|
|
219
|
+
|
|
220
|
+
sqft is asserted non-null upstream by _assert_inputs — no fallback panel here.
|
|
221
|
+
"""
|
|
222
|
+
sqft = subject["sqft"]
|
|
223
|
+
low_psf = valuation["sold_psf_low"]
|
|
224
|
+
mid_psf = valuation["sold_psf"]
|
|
225
|
+
high_psf = valuation["sold_psf_high"]
|
|
226
|
+
ask_psf = valuation["asking_psf"]
|
|
227
|
+
|
|
228
|
+
low_raw = int(sqft * low_psf)
|
|
229
|
+
mid_raw = int(sqft * mid_psf)
|
|
230
|
+
high_raw = int(sqft * high_psf)
|
|
231
|
+
ask_raw = int(sqft * ask_psf)
|
|
232
|
+
|
|
233
|
+
rows = [
|
|
234
|
+
('<div class="val-row headline">'
|
|
235
|
+
f'<div class="lbl">Headline range<small>{sqft:,} sqft × £{low_psf}–£{high_psf} sold £/sqft (Land Registry 70pc band)</small></div>'
|
|
236
|
+
f'<div class="num">{_range_money(low_raw, high_raw)}</div></div>'),
|
|
237
|
+
('<div class="val-row">'
|
|
238
|
+
f'<div class="lbl">Cross-check · asking £/sqft<small>{sqft:,} sqft × £{ask_psf} mean asking £/sqft</small></div>'
|
|
239
|
+
f'<div class="num">{_money(ask_raw)}</div></div>'),
|
|
240
|
+
]
|
|
241
|
+
|
|
242
|
+
adjustments = subject.get("adjustments") or []
|
|
243
|
+
if adjustments:
|
|
244
|
+
rows.append('<div class="val-row"><div class="lbl" style="font-family:var(--sans);font-weight:600;font-size:8pt;letter-spacing:2pt;text-transform:uppercase;color:var(--text-accent)">Adjustments</div><div class="num"> </div></div>')
|
|
245
|
+
for adj in adjustments:
|
|
246
|
+
delta = float(adj["delta_pct"])
|
|
247
|
+
cls = "pos" if delta > 0 else ("neg" if delta < 0 else "")
|
|
248
|
+
sign = "+" if delta >= 0 else ""
|
|
249
|
+
rows.append(
|
|
250
|
+
'<div class="val-row adj">'
|
|
251
|
+
f'<div class="lbl">{html.escape(adj["label"])}</div>'
|
|
252
|
+
f'<div class="num {cls}">{sign}{delta:.1f}%</div>'
|
|
253
|
+
"</div>"
|
|
254
|
+
)
|
|
255
|
+
|
|
256
|
+
delta_sum = sum(float(a["delta_pct"]) for a in adjustments)
|
|
257
|
+
factor = 1.0 + delta_sum / 100.0
|
|
258
|
+
low_final = int(low_raw * factor)
|
|
259
|
+
high_final = int(high_raw * factor)
|
|
260
|
+
|
|
261
|
+
final = (
|
|
262
|
+
'<div class="val-final">'
|
|
263
|
+
f'<div class="lbl">Final indicative range</div>'
|
|
264
|
+
f'<div class="num">{_range_money(low_final, high_final)}</div>'
|
|
265
|
+
"</div>"
|
|
266
|
+
)
|
|
267
|
+
|
|
268
|
+
return '<div class="val-panel">' + "".join(rows) + "</div>" + final
|
|
269
|
+
|
|
270
|
+
|
|
271
|
+
def _kpi_subject_sqft(subject: dict) -> tuple[str, str]:
|
|
272
|
+
sqft = subject["sqft"]
|
|
273
|
+
m2 = subject.get("sqft_m2")
|
|
274
|
+
sub = f"≈ {m2:.0f} m² · from EPC register" if m2 else "Square feet (operator)"
|
|
275
|
+
return f"{int(sqft):,}", sub
|
|
276
|
+
|
|
277
|
+
|
|
278
|
+
def _kpi_subject_epc(subject: dict) -> tuple[str, str]:
|
|
279
|
+
current = subject.get("epc_current")
|
|
280
|
+
potential = subject.get("epc_potential")
|
|
281
|
+
lodgement = subject.get("epc_lodgement")
|
|
282
|
+
if not current:
|
|
283
|
+
return "—", "No EPC on file"
|
|
284
|
+
sub_bits = []
|
|
285
|
+
if potential:
|
|
286
|
+
sub_bits.append(f"potential {potential}")
|
|
287
|
+
if lodgement:
|
|
288
|
+
sub_bits.append(f"lodged {lodgement}")
|
|
289
|
+
return current, " · ".join(sub_bits) or "EPC register"
|
|
290
|
+
|
|
291
|
+
|
|
292
|
+
# ---------------- input assertions ----------------
|
|
293
|
+
|
|
294
|
+
class PrevalAbort(RuntimeError):
|
|
295
|
+
"""Raised when a load-bearing input is missing or unverifiable.
|
|
296
|
+
|
|
297
|
+
.cause is the short slug logged as `reason=<cause>` and shown in the
|
|
298
|
+
operator's remediation hint.
|
|
299
|
+
"""
|
|
300
|
+
def __init__(self, cause: str, hint: str):
|
|
301
|
+
super().__init__(f"{cause}: {hint}")
|
|
302
|
+
self.cause = cause
|
|
303
|
+
self.hint = hint
|
|
304
|
+
|
|
305
|
+
|
|
306
|
+
def _assert_path_image(path_str: str, min_w: int = 1200, min_h: int = 800) -> None:
|
|
307
|
+
"""Existence + dimension check. Symlinks pointing into another property's
|
|
308
|
+
folder are rejected (cover-hero cross-contamination guard)."""
|
|
309
|
+
p = Path(path_str).expanduser()
|
|
310
|
+
if not p.exists():
|
|
311
|
+
raise PrevalAbort(
|
|
312
|
+
"subject-cover-hero-missing",
|
|
313
|
+
f"cover_hero path does not exist: {p}",
|
|
314
|
+
)
|
|
315
|
+
if p.is_symlink():
|
|
316
|
+
target = p.resolve()
|
|
317
|
+
parts = target.parts
|
|
318
|
+
if "properties" in parts:
|
|
319
|
+
# symlink → properties/<other-slug>/… is a cross-contamination
|
|
320
|
+
raise PrevalAbort(
|
|
321
|
+
"subject-cover-hero-symlinked-foreign",
|
|
322
|
+
f"cover_hero is a symlink into another property's folder: {target}",
|
|
323
|
+
)
|
|
324
|
+
# Dimensions. Avoid a Pillow dep; read PNG/JPEG headers directly.
|
|
325
|
+
w, h = _image_dimensions(p)
|
|
326
|
+
if w is None or h is None:
|
|
327
|
+
raise PrevalAbort(
|
|
328
|
+
"subject-cover-hero-unreadable",
|
|
329
|
+
f"cover_hero is not a readable PNG or JPEG: {p}",
|
|
330
|
+
)
|
|
331
|
+
if w < min_w or h < min_h:
|
|
332
|
+
raise PrevalAbort(
|
|
333
|
+
"subject-cover-hero-too-small",
|
|
334
|
+
f"cover_hero is {w}×{h}, below the {min_w}×{min_h} minimum: {p}",
|
|
335
|
+
)
|
|
336
|
+
|
|
337
|
+
|
|
338
|
+
def _image_dimensions(p: Path) -> tuple[int | None, int | None]:
|
|
339
|
+
"""Return (width, height) for PNG or JPEG. None on any failure."""
|
|
340
|
+
try:
|
|
341
|
+
with p.open("rb") as f:
|
|
342
|
+
head = f.read(24)
|
|
343
|
+
if len(head) < 24:
|
|
344
|
+
return None, None
|
|
345
|
+
if head[:8] == b"\x89PNG\r\n\x1a\n":
|
|
346
|
+
# IHDR width/height at bytes 16..24, big-endian uint32.
|
|
347
|
+
w = int.from_bytes(head[16:20], "big")
|
|
348
|
+
h = int.from_bytes(head[20:24], "big")
|
|
349
|
+
return w, h
|
|
350
|
+
if head[:2] == b"\xff\xd8":
|
|
351
|
+
f.seek(2)
|
|
352
|
+
while True:
|
|
353
|
+
b = f.read(1)
|
|
354
|
+
while b and b != b"\xff":
|
|
355
|
+
b = f.read(1)
|
|
356
|
+
marker = f.read(1)
|
|
357
|
+
if not marker:
|
|
358
|
+
return None, None
|
|
359
|
+
if 0xC0 <= marker[0] <= 0xCF and marker[0] not in (0xC4, 0xC8, 0xCC):
|
|
360
|
+
f.read(3) # length(2) + precision(1)
|
|
361
|
+
h = int.from_bytes(f.read(2), "big")
|
|
362
|
+
w = int.from_bytes(f.read(2), "big")
|
|
363
|
+
return w, h
|
|
364
|
+
seg_len = int.from_bytes(f.read(2), "big")
|
|
365
|
+
f.seek(seg_len - 2, 1)
|
|
366
|
+
return None, None
|
|
367
|
+
except OSError:
|
|
368
|
+
return None, None
|
|
369
|
+
|
|
370
|
+
|
|
371
|
+
def _assert_inputs(inputs: dict) -> None:
|
|
372
|
+
"""Fail loud on any missing or unverifiable load-bearing input.
|
|
373
|
+
|
|
374
|
+
Raises PrevalAbort with a named cause. No silent fallback paths.
|
|
375
|
+
"""
|
|
376
|
+
subject = inputs.get("subject") or {}
|
|
377
|
+
|
|
378
|
+
# subject.sqft / sqft_m2 — required, non-null integers.
|
|
379
|
+
if not subject.get("sqft"):
|
|
380
|
+
raise PrevalAbort(
|
|
381
|
+
"subject-sqft-missing",
|
|
382
|
+
"subject.sqft is null — run epc-key-register and re-derive, or paste subject.sqft as an operator override before invocation.",
|
|
383
|
+
)
|
|
384
|
+
if not subject.get("sqft_m2"):
|
|
385
|
+
raise PrevalAbort(
|
|
386
|
+
"subject-sqft-m2-missing",
|
|
387
|
+
"subject.sqft_m2 is null — the EPC m² figure must be supplied alongside sqft.",
|
|
388
|
+
)
|
|
389
|
+
|
|
390
|
+
# subject.cover_hero — required, exists, ≥ 1200×800, not symlinked into another property's folder.
|
|
391
|
+
hero = subject.get("cover_hero")
|
|
392
|
+
if not hero:
|
|
393
|
+
raise PrevalAbort(
|
|
394
|
+
"subject-cover-hero-missing",
|
|
395
|
+
"subject.cover_hero is required for the cover page.",
|
|
396
|
+
)
|
|
397
|
+
if not hero.startswith(("http://", "https://", "data:")):
|
|
398
|
+
_assert_path_image(hero)
|
|
399
|
+
|
|
400
|
+
# brand — must be the resolved DESIGN.md object, not a stub.
|
|
401
|
+
brand = inputs.get("brand") or {}
|
|
402
|
+
required_brand = ("name", "tagline", "primary", "primary_dark", "accent", "paper", "paper_banded", "rule")
|
|
403
|
+
missing_brand = [k for k in required_brand if not brand.get(k)]
|
|
404
|
+
if missing_brand:
|
|
405
|
+
raise PrevalAbort(
|
|
406
|
+
"brand-unresolved",
|
|
407
|
+
f"brand is missing required token(s) {missing_brand} — resolve from the agent's on-disk DESIGN.md before invocation.",
|
|
408
|
+
)
|
|
409
|
+
|
|
410
|
+
# agent_listings — non-empty.
|
|
411
|
+
if not inputs.get("agent_listings"):
|
|
412
|
+
raise PrevalAbort(
|
|
413
|
+
"agent-listings-empty",
|
|
414
|
+
"agent_listings is empty — paste at least one recent listing for the closing page.",
|
|
415
|
+
)
|
|
416
|
+
|
|
417
|
+
# market_summary.paragraphs — at least 2.
|
|
418
|
+
paras = (inputs.get("market_summary") or {}).get("paragraphs") or []
|
|
419
|
+
if len(paras) < 2:
|
|
420
|
+
raise PrevalAbort(
|
|
421
|
+
"market-summary-too-thin",
|
|
422
|
+
f"market_summary.paragraphs has {len(paras)} entries — minimum 2.",
|
|
423
|
+
)
|
|
424
|
+
|
|
425
|
+
|
|
426
|
+
# ---------------- render ----------------
|
|
427
|
+
|
|
131
428
|
def render(inputs: dict, out_dir: Path, template_path: Path | None = None) -> Path:
|
|
429
|
+
_assert_inputs(inputs)
|
|
430
|
+
|
|
132
431
|
template_path = template_path or (Path(__file__).parent / "template.html")
|
|
133
432
|
tmpl = template_path.read_text()
|
|
134
433
|
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
434
|
+
brand = inputs["brand"]
|
|
435
|
+
subject = inputs["subject"]
|
|
436
|
+
v = inputs["valuation"]
|
|
437
|
+
a = inputs["area"]
|
|
438
|
+
d = inputs["demand_trend"]
|
|
439
|
+
ms = inputs["market_summary"]
|
|
440
|
+
r = d["rental"]
|
|
441
|
+
|
|
442
|
+
out_dir.mkdir(parents=True, exist_ok=True)
|
|
443
|
+
|
|
444
|
+
subject_sqft, subject_sqft_sub = _kpi_subject_sqft(subject)
|
|
445
|
+
subject_epc, subject_epc_sub = _kpi_subject_epc(subject)
|
|
446
|
+
|
|
447
|
+
yoy = d.get("yoy_growth_pct")
|
|
448
|
+
if yoy is None and len(d.get("growth_series", [])) >= 2:
|
|
449
|
+
last, prev = d["growth_series"][-1]["price"], d["growth_series"][-2]["price"]
|
|
450
|
+
yoy = (last - prev) / prev * 100.0
|
|
451
|
+
yoy_str = f"{yoy:+.1f}%" if yoy is not None else "—"
|
|
452
|
+
|
|
453
|
+
psf = d.get("sales_per_month")
|
|
454
|
+
if psf is None:
|
|
455
|
+
psf = max(1, round(d["for_sale"] / 7)) # crude fallback only when not provided
|
|
139
456
|
|
|
140
457
|
slots = {
|
|
458
|
+
# branding
|
|
459
|
+
"brand_name": brand["name"],
|
|
460
|
+
"brand_tagline": brand["tagline"],
|
|
461
|
+
"brand_primary": brand["primary"],
|
|
462
|
+
"brand_primary_dark": brand["primary_dark"],
|
|
463
|
+
"brand_accent": brand["accent"],
|
|
464
|
+
"brand_paper": brand["paper"],
|
|
465
|
+
"brand_paper_banded": brand["paper_banded"],
|
|
466
|
+
"brand_rule": brand["rule"],
|
|
467
|
+
"wordmark_light": _wordmark(brand, "light", out_dir),
|
|
468
|
+
"wordmark_dark": _wordmark(brand, "dark", out_dir),
|
|
469
|
+
|
|
470
|
+
# cover + header
|
|
471
|
+
"subject_hero_path": _hero_asset(subject, out_dir),
|
|
141
472
|
"address": inputs["address"],
|
|
473
|
+
"address_short": _short_address(inputs["address"]),
|
|
142
474
|
"postcode": inputs["postcode"],
|
|
143
475
|
"generated": inputs["generated"],
|
|
144
476
|
|
|
145
|
-
|
|
146
|
-
"
|
|
147
|
-
"
|
|
148
|
-
"
|
|
149
|
-
"
|
|
150
|
-
"
|
|
151
|
-
|
|
152
|
-
"
|
|
153
|
-
"
|
|
154
|
-
"
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
"
|
|
158
|
-
"
|
|
159
|
-
|
|
160
|
-
"
|
|
161
|
-
"
|
|
162
|
-
"
|
|
163
|
-
"
|
|
164
|
-
"
|
|
165
|
-
|
|
166
|
-
"
|
|
167
|
-
"
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
"
|
|
477
|
+
# page 2 — subject valuation
|
|
478
|
+
"kpi_subject_sqft": subject_sqft,
|
|
479
|
+
"kpi_subject_sqft_sub": subject_sqft_sub,
|
|
480
|
+
"kpi_subject_beds": str(subject.get("beds") or "—"),
|
|
481
|
+
"kpi_subject_epc": subject_epc,
|
|
482
|
+
"kpi_subject_epc_sub": subject_epc_sub,
|
|
483
|
+
"kpi_subject_type": subject.get("type") or "—",
|
|
484
|
+
"kpi_subject_tenure": subject.get("tenure") or "Tenure unknown",
|
|
485
|
+
"valuation_block": _valuation_block(subject, v),
|
|
486
|
+
"subject_narrative": subject.get("narrative") or "",
|
|
487
|
+
|
|
488
|
+
# page 3 — market context
|
|
489
|
+
"summary_headline": ms["headline"],
|
|
490
|
+
"summary_paragraphs": _summary_paragraphs(ms["paragraphs"]),
|
|
491
|
+
"kpi_avg_asking": _money(v["avg_asking"]),
|
|
492
|
+
"kpi_avg_sold": _money(v["avg_sold"]),
|
|
493
|
+
"kpi_psf": _money(v["asking_psf"]),
|
|
494
|
+
"kpi_yoy": yoy_str,
|
|
495
|
+
"demand_rating": d["demand_rating"],
|
|
496
|
+
"gauge_position": ms["demand_gauge_position"],
|
|
497
|
+
"price_growth_note": ms["price_growth_note"],
|
|
498
|
+
"demand_note": ms["demand_note"],
|
|
499
|
+
"spark_svg": _spark_svg(d.get("growth_series", []), d.get("growth_psf_series", [])),
|
|
500
|
+
|
|
501
|
+
# page 4 — comparables
|
|
502
|
+
"sold_comps_rows": _sold_comps_rows(v.get("sold_comps", [])),
|
|
503
|
+
"asking_comps_tiles": _asking_comps_tiles(v.get("asking_comps", [])),
|
|
504
|
+
|
|
505
|
+
# page 5 — agent listings + sources
|
|
506
|
+
"agent_listings_cards": _agent_listings_cards(inputs["agent_listings"], out_dir),
|
|
172
507
|
}
|
|
173
508
|
|
|
174
|
-
# Escape
|
|
175
|
-
text_slots = {
|
|
176
|
-
|
|
509
|
+
# Escape only the plain-text slots; HTML fragments pass through untouched.
|
|
510
|
+
text_slots = {
|
|
511
|
+
"address", "address_short", "postcode", "generated",
|
|
512
|
+
"brand_name", "brand_tagline",
|
|
513
|
+
"kpi_subject_beds", "kpi_subject_epc", "kpi_subject_epc_sub",
|
|
514
|
+
"kpi_subject_sqft", "kpi_subject_sqft_sub",
|
|
515
|
+
"kpi_subject_type", "kpi_subject_tenure",
|
|
516
|
+
"summary_headline", "demand_rating",
|
|
517
|
+
"price_growth_note", "demand_note", "subject_narrative",
|
|
518
|
+
}
|
|
177
519
|
for k in text_slots:
|
|
178
|
-
slots[k] = html.escape(slots[k])
|
|
520
|
+
slots[k] = html.escape(str(slots[k]))
|
|
179
521
|
|
|
180
522
|
out = tmpl
|
|
181
523
|
for k, val in slots.items():
|
|
@@ -187,7 +529,6 @@ def render(inputs: dict, out_dir: Path, template_path: Path | None = None) -> Pa
|
|
|
187
529
|
"template has unfilled placeholders: " + ", ".join(sorted(set(leftovers)))
|
|
188
530
|
)
|
|
189
531
|
|
|
190
|
-
out_dir.mkdir(parents=True, exist_ok=True)
|
|
191
532
|
out_path = out_dir / "index.html"
|
|
192
533
|
out_path.write_text(out)
|
|
193
534
|
return out_path
|
|
@@ -212,11 +553,10 @@ _PAGE_OBJ_RE = re.compile(rb"/Type\s*/Page(?![sA-Za-z])")
|
|
|
212
553
|
|
|
213
554
|
|
|
214
555
|
def _count_pages(pdf_path: Path) -> int:
|
|
215
|
-
"""
|
|
216
|
-
raw
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
page-object streams — hence pdfinfo first when available.
|
|
556
|
+
"""pdfinfo first (handles /ObjStm-compressed page-object streams), with a
|
|
557
|
+
raw /Type /Page byte scan as fallback. The fallback is correct for current
|
|
558
|
+
Chrome --print-to-pdf output but would undercount if Chrome ever switches
|
|
559
|
+
to compressed page-object streams — hence pdfinfo first when present.
|
|
220
560
|
"""
|
|
221
561
|
pdfinfo = shutil.which("pdfinfo")
|
|
222
562
|
if pdfinfo:
|
|
@@ -231,13 +571,6 @@ def _count_pages(pdf_path: Path) -> int:
|
|
|
231
571
|
|
|
232
572
|
|
|
233
573
|
def assert_page_count(pdf_path: Path, expected: int) -> int:
|
|
234
|
-
"""Assert PDF page count == expected; raise RuntimeError otherwise.
|
|
235
|
-
|
|
236
|
-
Synchronous and reliable — does not depend on Spotlight (`mdls` returns
|
|
237
|
-
`(null)` until indexing finishes, which is async). Operators can still
|
|
238
|
-
verify externally with `mdls -name kMDItemNumberOfPages <pdf>` once
|
|
239
|
-
Spotlight catches up.
|
|
240
|
-
"""
|
|
241
574
|
n = _count_pages(pdf_path)
|
|
242
575
|
if n != expected:
|
|
243
576
|
raise RuntimeError(
|
|
@@ -247,16 +580,48 @@ def assert_page_count(pdf_path: Path, expected: int) -> int:
|
|
|
247
580
|
return n
|
|
248
581
|
|
|
249
582
|
|
|
583
|
+
def _abort(cause: str, hint: str, *, address: str, postcode: str, t0: float, out_dir: Path | None) -> None:
|
|
584
|
+
"""Emit the abort log line, scrub any partial output, exit non-zero."""
|
|
585
|
+
ms = int((time.monotonic() - t0) * 1000)
|
|
586
|
+
print(
|
|
587
|
+
f'[preval] aborted reason={cause} address="{address}" postcode={postcode} ms={ms}',
|
|
588
|
+
file=sys.stderr,
|
|
589
|
+
)
|
|
590
|
+
print(hint, file=sys.stderr)
|
|
591
|
+
if out_dir is not None:
|
|
592
|
+
for name in ("index.html",):
|
|
593
|
+
p = out_dir / name
|
|
594
|
+
if p.exists():
|
|
595
|
+
p.unlink()
|
|
596
|
+
# Any PDF whose stem matches filename_stem would be a partial — caller passes the stem.
|
|
597
|
+
sys.exit(2)
|
|
598
|
+
|
|
599
|
+
|
|
250
600
|
def main():
|
|
601
|
+
t0 = time.monotonic()
|
|
251
602
|
if len(sys.argv) < 2:
|
|
252
603
|
print("Usage: render.py <inputs.json>", file=sys.stderr)
|
|
253
604
|
sys.exit(2)
|
|
254
605
|
inputs = json.loads(Path(sys.argv[1]).read_text())
|
|
255
606
|
out_dir = Path(inputs["out_dir"]).expanduser()
|
|
256
607
|
base = inputs["filename_stem"]
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
608
|
+
address = inputs.get("address", "")
|
|
609
|
+
postcode = inputs.get("postcode", "")
|
|
610
|
+
try:
|
|
611
|
+
render(inputs, out_dir)
|
|
612
|
+
pdf = render_pdf(out_dir, f"{base}.pdf")
|
|
613
|
+
pages = assert_page_count(pdf, 5)
|
|
614
|
+
except PrevalAbort as e:
|
|
615
|
+
# Scrub partial PDF too, if rendered before page-count failure.
|
|
616
|
+
pdf_path = out_dir / f"{base}.pdf"
|
|
617
|
+
if pdf_path.exists():
|
|
618
|
+
pdf_path.unlink()
|
|
619
|
+
_abort(e.cause, e.hint, address=address, postcode=postcode, t0=t0, out_dir=out_dir)
|
|
620
|
+
except RuntimeError as e:
|
|
621
|
+
pdf_path = out_dir / f"{base}.pdf"
|
|
622
|
+
if pdf_path.exists():
|
|
623
|
+
pdf_path.unlink()
|
|
624
|
+
_abort("render-error", str(e), address=address, postcode=postcode, t0=t0, out_dir=out_dir)
|
|
260
625
|
print(f"HTML → {out_dir/'index.html'}")
|
|
261
626
|
print(f"PDF → {pdf} ({pages} pages)")
|
|
262
627
|
|