lovarch-cli 0.2.1__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.
- lovarch_cli/__init__.py +16 -0
- lovarch_cli/__main__.py +10 -0
- lovarch_cli/ai/__init__.py +21 -0
- lovarch_cli/ai/gateway.py +240 -0
- lovarch_cli/api.py +111 -0
- lovarch_cli/auth/__init__.py +32 -0
- lovarch_cli/auth/keyring_store.py +214 -0
- lovarch_cli/auth/local_server.py +165 -0
- lovarch_cli/auth/pkce.py +57 -0
- lovarch_cli/auth/session.py +189 -0
- lovarch_cli/cli.py +262 -0
- lovarch_cli/clients/__init__.py +33 -0
- lovarch_cli/clients/factory.py +54 -0
- lovarch_cli/clients/local_client.py +432 -0
- lovarch_cli/clients/lovarch_storage.py +174 -0
- lovarch_cli/clients/lovarch_supabase.py +295 -0
- lovarch_cli/clients/persistence.py +166 -0
- lovarch_cli/clients/storage.py +66 -0
- lovarch_cli/commands/__init__.py +10 -0
- lovarch_cli/commands/account.py +172 -0
- lovarch_cli/commands/audit.py +394 -0
- lovarch_cli/commands/config_cmd.py +80 -0
- lovarch_cli/commands/consolidate.py +217 -0
- lovarch_cli/commands/context_cmd.py +73 -0
- lovarch_cli/commands/dev.py +287 -0
- lovarch_cli/commands/do_cmd.py +120 -0
- lovarch_cli/commands/init.py +218 -0
- lovarch_cli/commands/jobs_cmd.py +95 -0
- lovarch_cli/commands/login.py +202 -0
- lovarch_cli/commands/mcp_cmd.py +26 -0
- lovarch_cli/commands/run.py +375 -0
- lovarch_cli/commands/signup.py +185 -0
- lovarch_cli/commands/status.py +243 -0
- lovarch_cli/commands/upgrade.py +108 -0
- lovarch_cli/commands/verifica_cmd.py +174 -0
- lovarch_cli/config.py +101 -0
- lovarch_cli/config_store.py +111 -0
- lovarch_cli/credits/__init__.py +35 -0
- lovarch_cli/credits/base.py +84 -0
- lovarch_cli/credits/factory.py +36 -0
- lovarch_cli/credits/local.py +34 -0
- lovarch_cli/credits/lovarch.py +56 -0
- lovarch_cli/i18n/__init__.py +27 -0
- lovarch_cli/i18n/loader.py +121 -0
- lovarch_cli/i18n/translations/en.json +168 -0
- lovarch_cli/i18n/translations/es.json +168 -0
- lovarch_cli/i18n/translations/it.json +168 -0
- lovarch_cli/i18n/translations/pt.json +168 -0
- lovarch_cli/mcp/__init__.py +9 -0
- lovarch_cli/mcp/server.py +199 -0
- lovarch_cli/mcp/tools.py +372 -0
- lovarch_cli/sample_downloader.py +255 -0
- lovarch_cli/squad/README.md +206 -0
- lovarch_cli/squad/agents/auditor-input.md +353 -0
- lovarch_cli/squad/agents/bim-engineer.md +404 -0
- lovarch_cli/squad/agents/briefing-architect.md +249 -0
- lovarch_cli/squad/agents/cad-engineer.md +278 -0
- lovarch_cli/squad/agents/capitolato-writer.md +256 -0
- lovarch_cli/squad/agents/computo-engineer.md +258 -0
- lovarch_cli/squad/agents/concept-designer.md +399 -0
- lovarch_cli/squad/agents/contratto-architect.md +243 -0
- lovarch_cli/squad/agents/deliverable-builder.md +253 -0
- lovarch_cli/squad/agents/energy-prelim.md +388 -0
- lovarch_cli/squad/agents/pratiche-it.md +251 -0
- lovarch_cli/squad/agents/progetto-chief.md +768 -0
- lovarch_cli/squad/agents/quality-dati.md +409 -0
- lovarch_cli/squad/agents/quality-misure.md +418 -0
- lovarch_cli/squad/agents/quality-normativa.md +417 -0
- lovarch_cli/squad/agents/quality-output.md +436 -0
- lovarch_cli/squad/agents/regolatorio-it.md +278 -0
- lovarch_cli/squad/checklists/handoff-quality-gate.md +232 -0
- lovarch_cli/squad/checklists/quality-dati-checklist.md +134 -0
- lovarch_cli/squad/checklists/quality-misure-checklist.md +139 -0
- lovarch_cli/squad/checklists/quality-normativa-checklist.md +121 -0
- lovarch_cli/squad/checklists/quality-output-checklist.md +116 -0
- lovarch_cli/squad/config.yaml +408 -0
- lovarch_cli/squad/data/CHANGELOG.md +272 -0
- lovarch_cli/squad/data/agents-prd.md +428 -0
- lovarch_cli/squad/data/architettura-progetto-rules.md +328 -0
- lovarch_cli/squad/data/handoff-card-template.md +231 -0
- lovarch_cli/squad/data/mocks/catasto-visura.json +72 -0
- lovarch_cli/squad/data/mocks/firma-envelope.json +43 -0
- lovarch_cli/squad/data/prezzario-lombardia-sample.json +312 -0
- lovarch_cli/squad/scripts/api_clients.py +206 -0
- lovarch_cli/squad/scripts/architect_profile.py +276 -0
- lovarch_cli/squad/scripts/deliverable_generators.py +844 -0
- lovarch_cli/squad/scripts/generate_attico_brera_dwg.py +369 -0
- lovarch_cli/squad/scripts/generate_chianti_dxf.py +368 -0
- lovarch_cli/squad/scripts/generate_chianti_images.py +223 -0
- lovarch_cli/squad/scripts/generate_real_sample_images.py +189 -0
- lovarch_cli/squad/scripts/generate_sample_assets.py +382 -0
- lovarch_cli/squad/scripts/lovarch_client.py +1046 -0
- lovarch_cli/squad/scripts/pipeline_runner.py +2095 -0
- lovarch_cli/squad/scripts/render_dxf_to_png.py +57 -0
- lovarch_cli/squad/scripts/run_palestra_demo.sh +277 -0
- lovarch_cli/squad/scripts/simulate_squad_execution.py +515 -0
- lovarch_cli/squad/scripts/validate-squad.py +383 -0
- lovarch_cli/squad/tasks/audit-input.md +146 -0
- lovarch_cli/squad/tasks/compute-metric.md +105 -0
- lovarch_cli/squad/tasks/consolidate-dossier.md +187 -0
- lovarch_cli/squad/tasks/generate-cad-plan.md +120 -0
- lovarch_cli/squad/tasks/generate-ifc-model.md +108 -0
- lovarch_cli/squad/tasks/write-capitolato.md +100 -0
- lovarch_cli/squad/templates/asseverazione-tecnica.md +126 -0
- lovarch_cli/squad/templates/capitolato-uni-11337.md +235 -0
- lovarch_cli/squad/templates/cila-comune-milano.md +177 -0
- lovarch_cli/squad/templates/contratto-cnappc.md +220 -0
- lovarch_cli/squad/workflows/dal-brief-al-cantiere.yaml +218 -0
- lovarch_cli/squad_loader.py +114 -0
- lovarch_cli/verify/__init__.py +15 -0
- lovarch_cli/verify/contratto.py +110 -0
- lovarch_cli/verify/dossier.py +97 -0
- lovarch_cli/verify/misure.py +83 -0
- lovarch_cli/verify/normativa.py +178 -0
- lovarch_cli/version.py +13 -0
- lovarch_cli/workflows/__init__.py +9 -0
- lovarch_cli/workflows/platform.py +212 -0
- lovarch_cli-0.2.1.dist-info/METADATA +232 -0
- lovarch_cli-0.2.1.dist-info/RECORD +122 -0
- lovarch_cli-0.2.1.dist-info/WHEEL +4 -0
- lovarch_cli-0.2.1.dist-info/entry_points.txt +3 -0
- lovarch_cli-0.2.1.dist-info/licenses/LICENSE +38 -0
|
@@ -0,0 +1,844 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Deliverable generators for Squad Architettura-Progetto.
|
|
3
|
+
|
|
4
|
+
Each function generates ONE deliverable type (PDF, DXF, XLSX, HTML, JSON, ZIP).
|
|
5
|
+
Returns bytes ready to upload to Storage.
|
|
6
|
+
|
|
7
|
+
Bucket allowed mime types reference (user-assets):
|
|
8
|
+
- application/pdf, image/png, image/jpeg
|
|
9
|
+
- application/vnd.openxmlformats-officedocument.spreadsheetml.sheet (xlsx)
|
|
10
|
+
- application/zip, text/plain, text/csv
|
|
11
|
+
- application/acad, application/x-acad, application/dwg, application/x-dwg
|
|
12
|
+
- (NO application/json or text/html · use text/plain)
|
|
13
|
+
"""
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
from io import BytesIO
|
|
16
|
+
from pathlib import Path
|
|
17
|
+
from typing import List, Dict, Any, Tuple, Optional
|
|
18
|
+
import json
|
|
19
|
+
import zipfile
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
# ============================================================================
|
|
23
|
+
# PDF · ReportLab
|
|
24
|
+
# ============================================================================
|
|
25
|
+
|
|
26
|
+
def gen_pdf(title: str, body_md: str, footer: str = "") -> bytes:
|
|
27
|
+
"""Generate PDF from title + markdown-ish body."""
|
|
28
|
+
from reportlab.lib.pagesizes import A4
|
|
29
|
+
from reportlab.lib.styles import getSampleStyleSheet, ParagraphStyle
|
|
30
|
+
from reportlab.lib.units import cm
|
|
31
|
+
from reportlab.platypus import SimpleDocTemplate, Paragraph, Spacer
|
|
32
|
+
from reportlab.lib.enums import TA_LEFT
|
|
33
|
+
|
|
34
|
+
buf = BytesIO()
|
|
35
|
+
doc = SimpleDocTemplate(buf, pagesize=A4, leftMargin=2*cm, rightMargin=2*cm,
|
|
36
|
+
topMargin=2*cm, bottomMargin=2*cm)
|
|
37
|
+
styles = getSampleStyleSheet()
|
|
38
|
+
title_style = ParagraphStyle("title", parent=styles["Title"], fontSize=20,
|
|
39
|
+
textColor="#A16207", spaceAfter=20)
|
|
40
|
+
h2_style = ParagraphStyle("h2", parent=styles["Heading2"], fontSize=14,
|
|
41
|
+
textColor="#A16207", spaceAfter=12, spaceBefore=8)
|
|
42
|
+
body_style = ParagraphStyle("body", parent=styles["BodyText"], fontSize=10,
|
|
43
|
+
leading=14, alignment=TA_LEFT)
|
|
44
|
+
footer_style = ParagraphStyle("footer", parent=styles["Italic"], fontSize=8,
|
|
45
|
+
textColor="#666666")
|
|
46
|
+
|
|
47
|
+
elems = [Paragraph(title, title_style), Spacer(1, 0.4*cm)]
|
|
48
|
+
for line in body_md.split("\n"):
|
|
49
|
+
ls = line.strip()
|
|
50
|
+
if not ls:
|
|
51
|
+
elems.append(Spacer(1, 0.2*cm))
|
|
52
|
+
elif ls.startswith("## "):
|
|
53
|
+
elems.append(Paragraph(ls[3:], h2_style))
|
|
54
|
+
elif ls.startswith("### "):
|
|
55
|
+
elems.append(Paragraph(f"<b>{ls[4:]}</b>", body_style))
|
|
56
|
+
else:
|
|
57
|
+
elems.append(Paragraph(ls.replace("**", "<b>", 1).replace("**", "</b>", 1), body_style))
|
|
58
|
+
if footer:
|
|
59
|
+
elems.append(Spacer(1, 1*cm))
|
|
60
|
+
elems.append(Paragraph(footer, footer_style))
|
|
61
|
+
doc.build(elems)
|
|
62
|
+
return buf.getvalue()
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
# ============================================================================
|
|
66
|
+
# XLSX · openpyxl
|
|
67
|
+
# ============================================================================
|
|
68
|
+
|
|
69
|
+
def gen_xlsx(headers: List[str], rows: List[List[Any]], sheet_name: str = "Sheet1",
|
|
70
|
+
col_widths: Dict[int, int] = None) -> bytes:
|
|
71
|
+
from openpyxl import Workbook
|
|
72
|
+
from openpyxl.styles import Font, PatternFill, Alignment, Border, Side
|
|
73
|
+
|
|
74
|
+
wb = Workbook()
|
|
75
|
+
ws = wb.active
|
|
76
|
+
ws.title = sheet_name[:31]
|
|
77
|
+
fill = PatternFill(start_color="A16207", end_color="A16207", fill_type="solid")
|
|
78
|
+
fnt = Font(color="FFFFFF", bold=True, size=11)
|
|
79
|
+
border = Border(bottom=Side(border_style="thin", color="A16207"))
|
|
80
|
+
for ci, h in enumerate(headers, 1):
|
|
81
|
+
c = ws.cell(row=1, column=ci, value=h)
|
|
82
|
+
c.fill = fill; c.font = fnt
|
|
83
|
+
c.alignment = Alignment(horizontal="center", vertical="center")
|
|
84
|
+
c.border = border
|
|
85
|
+
for ri, row in enumerate(rows, 2):
|
|
86
|
+
for ci, v in enumerate(row, 1):
|
|
87
|
+
ws.cell(row=ri, column=ci, value=v)
|
|
88
|
+
if col_widths:
|
|
89
|
+
for col_idx, w in col_widths.items():
|
|
90
|
+
ws.column_dimensions[chr(64 + col_idx)].width = w
|
|
91
|
+
else:
|
|
92
|
+
for col in ws.columns:
|
|
93
|
+
ml = max((len(str(c.value)) for c in col if c.value is not None), default=10)
|
|
94
|
+
ws.column_dimensions[col[0].column_letter].width = min(ml + 2, 50)
|
|
95
|
+
buf = BytesIO()
|
|
96
|
+
wb.save(buf)
|
|
97
|
+
return buf.getvalue()
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
# ============================================================================
|
|
101
|
+
# DXF · ezdxf
|
|
102
|
+
# ============================================================================
|
|
103
|
+
|
|
104
|
+
def gen_dxf_pianta_progetto(out_path: Path) -> int:
|
|
105
|
+
"""Generate pianta-progetto.dxf · 9 ambienti UNI ISO 5457.
|
|
106
|
+
|
|
107
|
+
Layer names follow the ISO 13567 / AIA "CAD-A-*" discipline-prefix convention
|
|
108
|
+
expected by the @quality-misure Tier 2 verifier (rules.md §3.3). All 9 rooms
|
|
109
|
+
are labelled (INGRESSO, SOGGIORNO, CUCINA, STUDIO, CAMERA, BAGNO, LAVANDERIA)
|
|
110
|
+
and the cartiglio carries the 5 mandatory CNAPPC fields (PROGETTO, CLIENTE,
|
|
111
|
+
ARCHITETTO, SCALA, DATA) on the dedicated CAD-A-CART layer.
|
|
112
|
+
"""
|
|
113
|
+
import ezdxf
|
|
114
|
+
from ezdxf.enums import TextEntityAlignment
|
|
115
|
+
|
|
116
|
+
doc = ezdxf.new("R2018", setup=True)
|
|
117
|
+
msp = doc.modelspace()
|
|
118
|
+
# ISO layers · color index per UNI ISO 5457 convention
|
|
119
|
+
layers = [
|
|
120
|
+
("CAD-A-WALL", 7), # interior walls (new)
|
|
121
|
+
("CAD-A-WALL-EXT", 4), # exterior / perimeter walls
|
|
122
|
+
("CAD-A-WALL-DEMO", 1), # demolition (reserved)
|
|
123
|
+
("CAD-A-DOOR", 3), # doors
|
|
124
|
+
("CAD-A-WIND", 5), # windows
|
|
125
|
+
("CAD-A-DIM", 2), # dimensions / quote
|
|
126
|
+
("CAD-A-TEXT", 6), # room labels and notes
|
|
127
|
+
("CAD-A-FURN", 8), # furniture (reserved)
|
|
128
|
+
("CAD-A-SYMB", 4), # symbols (reserved)
|
|
129
|
+
("CAD-A-CART", 7), # cartiglio / title block
|
|
130
|
+
]
|
|
131
|
+
for name, color in layers:
|
|
132
|
+
if name not in doc.layers:
|
|
133
|
+
doc.layers.add(name).color = color
|
|
134
|
+
|
|
135
|
+
# Outline (exterior) + interior perimeter
|
|
136
|
+
msp.add_lwpolyline([(0, 0), (12000, 0), (12000, 10000), (0, 10000), (0, 0)],
|
|
137
|
+
dxfattribs={"layer": "CAD-A-WALL-EXT"})
|
|
138
|
+
msp.add_lwpolyline([(300, 300), (11700, 300), (11700, 9700), (300, 9700), (300, 300)],
|
|
139
|
+
dxfattribs={"layer": "CAD-A-WALL-EXT"})
|
|
140
|
+
|
|
141
|
+
walls = [
|
|
142
|
+
((5500, 300), (5500, 4500)), ((9500, 300), (9500, 4500)),
|
|
143
|
+
((9500, 4500), (11700, 4500)), ((5500, 4500), (9500, 4500)),
|
|
144
|
+
((5500, 6500), (5500, 9700)), ((5500, 6500), (9000, 6500)),
|
|
145
|
+
((4500, 6000), (4500, 9700)), ((4500, 6000), (5500, 6000)),
|
|
146
|
+
((7500, 6500), (7500, 9700)),
|
|
147
|
+
]
|
|
148
|
+
for s, e in walls:
|
|
149
|
+
msp.add_line(s, e, dxfattribs={"layer": "CAD-A-WALL"})
|
|
150
|
+
|
|
151
|
+
doors = [(1500, 300, 1900, 300), (5500, 2500, 5500, 2900),
|
|
152
|
+
(5500, 5400, 5500, 5800), (4500, 7500, 4500, 7900),
|
|
153
|
+
(7500, 8000, 7500, 8400), (9500, 1800, 9500, 2200)]
|
|
154
|
+
for x1, y1, x2, y2 in doors:
|
|
155
|
+
msp.add_line((x1, y1), (x2, y2), dxfattribs={"layer": "CAD-A-DOOR", "color": 3})
|
|
156
|
+
|
|
157
|
+
windows = [(1000, 9700, 2000, 9700), (2500, 9700, 3500, 9700),
|
|
158
|
+
(6000, 9700, 7000, 9700), (7500, 9700, 8500, 9700),
|
|
159
|
+
(10500, 1500, 10500, 2500), (10500, 3000, 10500, 4000),
|
|
160
|
+
(1500, 300, 2500, 300), (4000, 300, 5000, 300)]
|
|
161
|
+
for x1, y1, x2, y2 in windows:
|
|
162
|
+
msp.add_line((x1, y1), (x2, y2), dxfattribs={"layer": "CAD-A-WIND", "color": 5})
|
|
163
|
+
|
|
164
|
+
# Quote / dimension marker (so CAD-A-DIM layer carries at least one entity)
|
|
165
|
+
msp.add_line((300, -200), (11700, -200), dxfattribs={"layer": "CAD-A-DIM", "color": 2})
|
|
166
|
+
msp.add_text("12.00 m", dxfattribs={"layer": "CAD-A-DIM", "height": 150, "color": 2})\
|
|
167
|
+
.set_placement((6000, -350), align=TextEntityAlignment.CENTER)
|
|
168
|
+
|
|
169
|
+
# Room labels · all 9 ambienti named with canonical room keywords so the
|
|
170
|
+
# @quality-misure verifier finds INGRESSO/SOGGIORNO/CUCINA/STUDIO/CAMERA/
|
|
171
|
+
# BAGNO/LAVANDERIA.
|
|
172
|
+
labels = [
|
|
173
|
+
(1500, 5200, "INGRESSO"), (1500, 4800, "6.0 m²"),
|
|
174
|
+
(3000, 3000, "SOGGIORNO · CUCINA"), (3000, 2500, "47.5 m²"),
|
|
175
|
+
(7500, 2300, "CAMERA PADRONALE"), (7500, 1900, "18.0 m²"),
|
|
176
|
+
(10600, 2000, "BAGNO PADRONALE"), (10600, 1700, "7.0 m²"),
|
|
177
|
+
(7250, 5500, "STUDIO MARCO"), (7250, 5100, "13.5 m²"),
|
|
178
|
+
(2200, 8000, "CAMERA SOFIA"), (2200, 7600, "12.0 m²"),
|
|
179
|
+
(6500, 8200, "BAGNO 2"), (6500, 7900, "5.5 m²"),
|
|
180
|
+
(9200, 7800, "LAVANDERIA"), (9200, 7400, "3.5 m²"),
|
|
181
|
+
]
|
|
182
|
+
for x, y, txt in labels:
|
|
183
|
+
msp.add_text(txt, dxfattribs={"layer": "CAD-A-TEXT", "height": 200, "color": 6})\
|
|
184
|
+
.set_placement((x, y), align=TextEntityAlignment.CENTER)
|
|
185
|
+
|
|
186
|
+
# Cartiglio · on dedicated CAD-A-CART layer with the 5 CNAPPC fields labelled
|
|
187
|
+
# explicitly (PROGETTO / CLIENTE / ARCHITETTO / SCALA / DATA).
|
|
188
|
+
msp.add_lwpolyline([(0, -1700), (12000, -1700), (12000, -500), (0, -500), (0, -1700)],
|
|
189
|
+
dxfattribs={"layer": "CAD-A-CART"})
|
|
190
|
+
cart = [
|
|
191
|
+
(200, -700, "PROGETTO: Attico Brera · Via Fiori Chiari 17 · Milano"),
|
|
192
|
+
(200, -1000, "CLIENTE: Marco Rossini & Giulia Bianchi · F.356 M.127 Sub.12"),
|
|
193
|
+
(200, -1300, "ARCHITETTO: Pablo Ruan · OAPPC Milano"),
|
|
194
|
+
(200, -1600, "SCALA: 1:50 · DATA: 25/04/2026 · Tav. A.02 pianta progetto"),
|
|
195
|
+
]
|
|
196
|
+
for x, y, txt in cart:
|
|
197
|
+
msp.add_text(txt, dxfattribs={"layer": "CAD-A-CART", "height": 150, "color": 6})\
|
|
198
|
+
.set_placement((x, y), align=TextEntityAlignment.LEFT)
|
|
199
|
+
|
|
200
|
+
out_path.parent.mkdir(parents=True, exist_ok=True)
|
|
201
|
+
doc.saveas(str(out_path))
|
|
202
|
+
return out_path.stat().st_size
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
def gen_dxf_sezione(label: str, out_path: Path) -> int:
|
|
206
|
+
"""Generate sezione DXF (AA or BB) · simple section view."""
|
|
207
|
+
import ezdxf
|
|
208
|
+
from ezdxf.enums import TextEntityAlignment
|
|
209
|
+
|
|
210
|
+
doc = ezdxf.new("R2018", setup=True)
|
|
211
|
+
msp = doc.modelspace()
|
|
212
|
+
for n, c in [("CAD-A-WALL", 7), ("CAD-A-DIM", 2), ("CAD-A-TEXT", 6), ("CAD-A-SLAB", 5)]:
|
|
213
|
+
if n not in doc.layers:
|
|
214
|
+
doc.layers.add(n).color = c
|
|
215
|
+
|
|
216
|
+
# Section box
|
|
217
|
+
msp.add_lwpolyline([(0, 0), (10000, 0), (10000, 3000), (0, 3000), (0, 0)],
|
|
218
|
+
dxfattribs={"layer": "CAD-A-WALL"})
|
|
219
|
+
# Floor + ceiling + interior walls
|
|
220
|
+
for y in [0, 200, 2700, 3000]:
|
|
221
|
+
msp.add_line((0, y), (10000, y), dxfattribs={"layer": "CAD-A-SLAB"})
|
|
222
|
+
for x in [3000, 6500]:
|
|
223
|
+
msp.add_line((x, 200), (x, 2700), dxfattribs={"layer": "CAD-A-WALL"})
|
|
224
|
+
|
|
225
|
+
msp.add_text(f"SEZIONE {label}", dxfattribs={"layer": "CAD-A-TEXT", "height": 250, "color": 6})\
|
|
226
|
+
.set_placement((5000, 3500), align=TextEntityAlignment.CENTER)
|
|
227
|
+
msp.add_text("scala 1:50 · h. 290-310 cm",
|
|
228
|
+
dxfattribs={"layer": "CAD-A-DIM", "height": 150, "color": 6})\
|
|
229
|
+
.set_placement((5000, -300), align=TextEntityAlignment.CENTER)
|
|
230
|
+
|
|
231
|
+
out_path.parent.mkdir(parents=True, exist_ok=True)
|
|
232
|
+
doc.saveas(str(out_path))
|
|
233
|
+
return out_path.stat().st_size
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
# ============================================================================
|
|
237
|
+
# HTML · presentazione cliente con DS V8
|
|
238
|
+
# ============================================================================
|
|
239
|
+
|
|
240
|
+
def gen_html_presentation(project_data: Dict[str, Any], moodboard_url: str,
|
|
241
|
+
render_urls: List[Tuple[str, str]],
|
|
242
|
+
foto_urls: Optional[List[Tuple[str, str]]] = None,
|
|
243
|
+
pinterest_urls: Optional[List[Tuple[str, str]]] = None,
|
|
244
|
+
color_palette: Optional[List[Dict]] = None,
|
|
245
|
+
docs_by_category: Optional[Dict[str, List[Dict]]] = None,
|
|
246
|
+
architect_profile: Optional[Dict[str, Any]] = None) -> str:
|
|
247
|
+
"""Generate FULL visual presentation HTML with DS V8.
|
|
248
|
+
|
|
249
|
+
Sections (in order):
|
|
250
|
+
- Hero · project info + key stats (chart-style cards)
|
|
251
|
+
- Cliente profile · Marco + Giulia + Sofia + Otto
|
|
252
|
+
- Programma spaziale · 9 ambienti grid
|
|
253
|
+
- Stato attuale · galleria 6 foto (BEFORE)
|
|
254
|
+
- Pinterest references · 6 immagini stile cliente
|
|
255
|
+
- Concept design · moodboard composto + paleta colori (swatch)
|
|
256
|
+
- Render progetto · 5 ambienti AI · BEFORE/AFTER side-by-side
|
|
257
|
+
- Budget · gráfico bar split categorías
|
|
258
|
+
- Timeline · gantt 90gg cantiere · SAL milestones
|
|
259
|
+
- Dossier · 28 deliverables grouped by category com thumbnails
|
|
260
|
+
- Imprese pre-selezionate · 3 cards
|
|
261
|
+
- Footer · architetto info + contratto info
|
|
262
|
+
"""
|
|
263
|
+
foto_urls = foto_urls or []
|
|
264
|
+
pinterest_urls = pinterest_urls or []
|
|
265
|
+
profile = architect_profile or {}
|
|
266
|
+
brand = profile.get("brand", {})
|
|
267
|
+
fiscal = profile.get("fiscal", {})
|
|
268
|
+
creds = profile.get("italian_credentials", {})
|
|
269
|
+
style_data = profile.get("style", {})
|
|
270
|
+
|
|
271
|
+
# Architect derived strings
|
|
272
|
+
arch_name_h = profile.get("full_name") or "[Architetto]"
|
|
273
|
+
arch_company_h = profile.get("company_name") or brand.get("name") or ""
|
|
274
|
+
arch_logo = brand.get("logo_url", "")
|
|
275
|
+
arch_email_h = profile.get("email", "")
|
|
276
|
+
arch_phone_h = profile.get("phone", "")
|
|
277
|
+
arch_pec_h = creds.get("pec", "")
|
|
278
|
+
arch_ordine_h = creds.get("ordine_professionale", "")
|
|
279
|
+
arch_matricola_h = creds.get("matricola", "")
|
|
280
|
+
arch_address_h = ""
|
|
281
|
+
if fiscal.get("street"):
|
|
282
|
+
arch_address_h = fiscal["street"]
|
|
283
|
+
if fiscal.get("city"):
|
|
284
|
+
arch_address_h += f", {fiscal.get('postal_code', '')} {fiscal['city']}"
|
|
285
|
+
if fiscal.get("country"):
|
|
286
|
+
arch_address_h += f" ({fiscal['country']})"
|
|
287
|
+
|
|
288
|
+
color_palette = color_palette or [
|
|
289
|
+
{"hex": "#D4CBC0", "name": "Beige caldo", "usage": "Pareti", "percentage": 30},
|
|
290
|
+
{"hex": "#B8865A", "name": "Terra di siena", "usage": "Accenti", "percentage": 20},
|
|
291
|
+
{"hex": "#87A47A", "name": "Verde salvia", "usage": "Sofia", "percentage": 15},
|
|
292
|
+
{"hex": "#C8A296", "name": "Rosa antico", "usage": "Tessili", "percentage": 10},
|
|
293
|
+
{"hex": "#F2EDE4", "name": "Crema", "usage": "Soffitti", "percentage": 15},
|
|
294
|
+
{"hex": "#5C7B6F", "name": "Verde profondo", "usage": "Studio", "percentage": 10},
|
|
295
|
+
]
|
|
296
|
+
docs_by_category = docs_by_category or {}
|
|
297
|
+
|
|
298
|
+
foto_cards = "\n".join(
|
|
299
|
+
f'<div class="gallery-card"><img src="{url}" alt="{name}" loading="lazy"/><div class="caption">{name}</div></div>'
|
|
300
|
+
for name, url in foto_urls
|
|
301
|
+
) if foto_urls else '<p class="muted">Foto stato attuale non disponibili</p>'
|
|
302
|
+
|
|
303
|
+
pin_cards = "\n".join(
|
|
304
|
+
f'<div class="gallery-card"><img src="{url}" alt="{name}" loading="lazy"/><div class="caption">{name}</div></div>'
|
|
305
|
+
for name, url in pinterest_urls
|
|
306
|
+
) if pinterest_urls else '<p class="muted">References non disponibili</p>'
|
|
307
|
+
|
|
308
|
+
render_cards = "\n".join(
|
|
309
|
+
f'<div class="render-card"><img src="{url}" alt="{ambiente}" loading="lazy"/>'
|
|
310
|
+
f'<div class="caption">{ambiente.replace("-progetto", "").replace("-", " ").title()}</div></div>'
|
|
311
|
+
for ambiente, url in render_urls
|
|
312
|
+
)
|
|
313
|
+
|
|
314
|
+
# Before/after pairs (match foto with corresponding render by ambiente)
|
|
315
|
+
foto_by_room = {}
|
|
316
|
+
for name, url in foto_urls:
|
|
317
|
+
# 03-soggiorno.jpg → soggiorno
|
|
318
|
+
slug = name.split(".")[0].split("-", 1)[-1] if "-" in name else name
|
|
319
|
+
foto_by_room[slug] = (name, url)
|
|
320
|
+
|
|
321
|
+
ba_pairs = []
|
|
322
|
+
for ambiente, render_url in render_urls:
|
|
323
|
+
room_slug = ambiente.replace("-progetto", "")
|
|
324
|
+
if room_slug in foto_by_room:
|
|
325
|
+
foto_name, foto_url = foto_by_room[room_slug]
|
|
326
|
+
ba_pairs.append((room_slug, foto_url, render_url))
|
|
327
|
+
|
|
328
|
+
ba_cards = "\n".join(f"""
|
|
329
|
+
<div class="ba-card">
|
|
330
|
+
<div class="ba-header">{slug.replace("-", " ").title()}</div>
|
|
331
|
+
<div class="ba-grid">
|
|
332
|
+
<div class="ba-side"><span class="ba-tag tag-before">PRIMA · 1980s</span><img src="{f}" alt="before"/></div>
|
|
333
|
+
<div class="ba-side"><span class="ba-tag tag-after">DOPO · progetto</span><img src="{r}" alt="after"/></div>
|
|
334
|
+
</div>
|
|
335
|
+
</div>""" for slug, f, r in ba_pairs)
|
|
336
|
+
|
|
337
|
+
palette_swatches = "\n".join(
|
|
338
|
+
f'<div class="swatch"><div class="swatch-color" style="background:{c["hex"]}"></div>'
|
|
339
|
+
f'<div class="swatch-info"><div class="swatch-name">{c["name"]}</div>'
|
|
340
|
+
f'<div class="swatch-meta">{c["hex"]} · {c["percentage"]}%</div>'
|
|
341
|
+
f'<div class="swatch-usage">{c.get("usage", "")}</div></div></div>'
|
|
342
|
+
for c in color_palette
|
|
343
|
+
)
|
|
344
|
+
|
|
345
|
+
# Budget split chart (CSS bars)
|
|
346
|
+
budget_categories = [
|
|
347
|
+
("Opere edili", 30808, "#A16207"),
|
|
348
|
+
("Impianti", 40175, "#B8865A"),
|
|
349
|
+
("Finiture", 48310, "#87A47A"),
|
|
350
|
+
("Bagni e cucina", 47610, "#C8A296"),
|
|
351
|
+
("Sicurezza + IVA", 13097, "#5C7B6F"),
|
|
352
|
+
]
|
|
353
|
+
total_budget = sum(v for _, v, _ in budget_categories)
|
|
354
|
+
budget_bars = "\n".join(
|
|
355
|
+
f'<div class="bar-row"><div class="bar-label">{n}</div>'
|
|
356
|
+
f'<div class="bar-track"><div class="bar-fill" style="width:{v/total_budget*100:.1f}%;background:{col}"></div>'
|
|
357
|
+
f'<div class="bar-value">€ {v:,.0f}</div></div></div>'.replace(",", ".")
|
|
358
|
+
for n, v, col in budget_categories
|
|
359
|
+
)
|
|
360
|
+
|
|
361
|
+
# Timeline gantt
|
|
362
|
+
phases_timeline = [
|
|
363
|
+
("Progettazione", "25/04", "05/06", 26.7, "#A16207"),
|
|
364
|
+
("Pratiche edilizie", "06/06", "30/06", 16.7, "#B8865A"),
|
|
365
|
+
("Demolizioni", "01/07", "10/07", 6.7, "#5C7B6F"),
|
|
366
|
+
("Impianti", "11/07", "31/07", 14.0, "#87A47A"),
|
|
367
|
+
("Tramezze + intonaci", "01/08", "20/08", 13.3, "#C8A296"),
|
|
368
|
+
("Pavimenti + restauri", "21/08", "10/09", 14.0, "#A16207"),
|
|
369
|
+
("Cucina + bagni", "11/09", "30/09", 13.3, "#B8865A"),
|
|
370
|
+
("Verniciature + finiture", "01/10", "20/10", 13.3, "#87A47A"),
|
|
371
|
+
("Collaudo + consegna", "21/10", "31/10", 7.3, "#5C7B6F"),
|
|
372
|
+
]
|
|
373
|
+
timeline_rows = "\n".join(
|
|
374
|
+
f'<div class="tl-row"><div class="tl-label">{n}</div><div class="tl-bar" style="background:{col};width:{w}%">'
|
|
375
|
+
f'<span>{s} → {e}</span></div></div>'
|
|
376
|
+
for n, s, e, w, col in phases_timeline
|
|
377
|
+
)
|
|
378
|
+
|
|
379
|
+
sal_milestones = """
|
|
380
|
+
<div class="sal-row"><div class="sal-marker">15%</div><div><strong>SAL 1 · Firma contratto</strong> · 25/04/2026 · € 3.300</div></div>
|
|
381
|
+
<div class="sal-row"><div class="sal-marker">25%</div><div><strong>SAL 2 · CILA depositata</strong> · 12/06/2026 · € 5.500</div></div>
|
|
382
|
+
<div class="sal-row"><div class="sal-marker">25%</div><div><strong>SAL 3 · 50% lavori</strong> · 15/08/2026 · € 5.500</div></div>
|
|
383
|
+
<div class="sal-row"><div class="sal-marker">35%</div><div><strong>SAL 4 · Consegna chiavi</strong> · 31/10/2026 · € 7.700</div></div>
|
|
384
|
+
"""
|
|
385
|
+
|
|
386
|
+
# Documents grouped
|
|
387
|
+
cat_meta = {
|
|
388
|
+
"cliente": ("👤", "Per il Cliente", "#CA8A04"),
|
|
389
|
+
"comune": ("🏛", "Per il Comune", "#A855F7"),
|
|
390
|
+
"impresa": ("👷", "Per l'Impresa", "#F59E0B"),
|
|
391
|
+
"studio": ("📋", "Studio interno", "#10B981"),
|
|
392
|
+
"ingegneri": ("⚙", "Ingegneri", "#64748B"),
|
|
393
|
+
"altro": ("📁", "Altri", "#A1A1AA"),
|
|
394
|
+
}
|
|
395
|
+
cat_sections = ""
|
|
396
|
+
for cat, files in docs_by_category.items():
|
|
397
|
+
if not files:
|
|
398
|
+
continue
|
|
399
|
+
icon, label, color = cat_meta.get(cat, ("📁", cat.title(), "#A1A1AA"))
|
|
400
|
+
items = "\n".join(
|
|
401
|
+
f'<a href="{f.get("public_url", "#")}" target="_blank" class="doc-item">'
|
|
402
|
+
f'<span class="doc-name">{f["name"]}</span>'
|
|
403
|
+
f'<span class="doc-meta">{f.get("size", 0) // 1024} KB</span></a>'
|
|
404
|
+
for f in files
|
|
405
|
+
)
|
|
406
|
+
cat_sections += f"""
|
|
407
|
+
<div class="cat-card" style="border-top: 4px solid {color}">
|
|
408
|
+
<div class="cat-header">
|
|
409
|
+
<span class="cat-icon">{icon}</span>
|
|
410
|
+
<h3>{label}</h3>
|
|
411
|
+
<span class="cat-count">{len(files)}</span>
|
|
412
|
+
</div>
|
|
413
|
+
<div class="cat-items">{items}</div>
|
|
414
|
+
</div>"""
|
|
415
|
+
|
|
416
|
+
return f"""<!DOCTYPE html>
|
|
417
|
+
<html lang="it"><head>
|
|
418
|
+
<meta charset="UTF-8">
|
|
419
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
420
|
+
<title>Attico Brera · Presentazione · Marco Rossini & Giulia Bianchi</title>
|
|
421
|
+
<link rel="preconnect" href="https://fonts.googleapis.com">
|
|
422
|
+
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
|
423
|
+
<link href="https://fonts.googleapis.com/css2?family=Playfair+Display:wght@400;700;900&family=Outfit:wght@400;500;600;700&family=DM+Sans:wght@400;500;600;700&family=Inter:wght@400;500;600&display=swap" rel="stylesheet">
|
|
424
|
+
<style>
|
|
425
|
+
:root {{
|
|
426
|
+
--bg: #FAF9F7;
|
|
427
|
+
--card: #FFFFFF;
|
|
428
|
+
--text: #18181B;
|
|
429
|
+
--text-muted: #71717A;
|
|
430
|
+
--gold: #A16207;
|
|
431
|
+
--gold-light: #CA8A04;
|
|
432
|
+
--gold-bg: rgba(161,98,7,0.08);
|
|
433
|
+
--emerald: #10B981;
|
|
434
|
+
--amber: #F59E0B;
|
|
435
|
+
--red: #EF4444;
|
|
436
|
+
--border: #E5E5E5;
|
|
437
|
+
}}
|
|
438
|
+
* {{ box-sizing: border-box; }}
|
|
439
|
+
body {{ background: var(--bg); color: var(--text); font-family: 'Inter', sans-serif; margin: 0; line-height: 1.5; }}
|
|
440
|
+
.font-display {{ font-family: 'Playfair Display', serif; }}
|
|
441
|
+
.font-outfit {{ font-family: 'Outfit', sans-serif; }}
|
|
442
|
+
.font-dm {{ font-family: 'DM Sans', sans-serif; }}
|
|
443
|
+
.muted {{ color: var(--text-muted); }}
|
|
444
|
+
|
|
445
|
+
/* HERO */
|
|
446
|
+
.hero {{
|
|
447
|
+
background: linear-gradient(135deg, #18181B 0%, #2A1810 50%, #A16207 100%);
|
|
448
|
+
color: white;
|
|
449
|
+
padding: 80px 40px 60px;
|
|
450
|
+
position: relative;
|
|
451
|
+
overflow: hidden;
|
|
452
|
+
}}
|
|
453
|
+
.hero::before {{
|
|
454
|
+
content: '';
|
|
455
|
+
position: absolute;
|
|
456
|
+
inset: 0;
|
|
457
|
+
background-image: radial-gradient(circle at 20% 50%, rgba(255,255,255,0.05) 0%, transparent 30%);
|
|
458
|
+
}}
|
|
459
|
+
.hero-inner {{ max-width: 1200px; margin: 0 auto; position: relative; }}
|
|
460
|
+
.hero .chip {{
|
|
461
|
+
display: inline-block; padding: 6px 16px; background: rgba(255,255,255,0.12);
|
|
462
|
+
border: 1px solid rgba(255,255,255,0.2); border-radius: 999px;
|
|
463
|
+
font-family: 'DM Sans'; font-size: 13px; font-weight: 500; margin-bottom: 24px;
|
|
464
|
+
}}
|
|
465
|
+
.hero h1 {{ font-family: 'Playfair Display', serif; font-size: 64px; margin: 0; line-height: 1; font-weight: 700; }}
|
|
466
|
+
.hero h1 em {{ color: #FCD34D; font-style: italic; font-weight: 400; }}
|
|
467
|
+
.hero .subtitle {{ margin-top: 16px; opacity: 0.85; font-size: 18px; }}
|
|
468
|
+
.hero-stats {{ display: grid; grid-template-columns: repeat(4, 1fr); gap: 16px; margin-top: 48px; }}
|
|
469
|
+
.stat-card {{ background: rgba(255,255,255,0.08); border: 1px solid rgba(255,255,255,0.15); border-radius: 12px; padding: 20px; backdrop-filter: blur(10px); }}
|
|
470
|
+
.stat-num {{ font-family: 'DM Sans'; font-size: 36px; font-weight: 700; color: #FCD34D; }}
|
|
471
|
+
.stat-label {{ font-size: 13px; opacity: 0.75; margin-top: 4px; }}
|
|
472
|
+
|
|
473
|
+
/* SECTION */
|
|
474
|
+
section.block {{ padding: 60px 40px; max-width: 1200px; margin: 0 auto; }}
|
|
475
|
+
.sec-title {{ display: flex; align-items: baseline; gap: 16px; margin-bottom: 32px; padding-bottom: 16px; border-bottom: 1px solid var(--border); }}
|
|
476
|
+
.sec-num {{ font-family: 'DM Sans'; font-size: 13px; color: var(--gold); font-weight: 700; letter-spacing: 0.1em; }}
|
|
477
|
+
.sec-title h2 {{ font-family: 'Playfair Display', serif; font-size: 36px; margin: 0; line-height: 1; }}
|
|
478
|
+
.sec-intro {{ font-size: 16px; color: var(--text-muted); max-width: 720px; margin-bottom: 32px; }}
|
|
479
|
+
|
|
480
|
+
/* CLIENT PROFILE */
|
|
481
|
+
.profile-grid {{ display: grid; grid-template-columns: 1fr 2fr; gap: 24px; }}
|
|
482
|
+
.profile-card {{ background: var(--card); padding: 28px; border-radius: 16px; border: 1px solid var(--border); }}
|
|
483
|
+
.profile-avatar {{ width: 72px; height: 72px; border-radius: 50%; background: linear-gradient(135deg, var(--gold) 0%, var(--gold-light) 100%); color: white; display: flex; align-items: center; justify-content: center; font-family: 'Playfair Display'; font-size: 32px; font-weight: 700; margin-bottom: 20px; }}
|
|
484
|
+
.profile-row {{ display: flex; justify-content: space-between; padding: 8px 0; border-bottom: 1px solid var(--border); font-size: 14px; }}
|
|
485
|
+
.profile-row:last-child {{ border-bottom: none; }}
|
|
486
|
+
.profile-row .label {{ color: var(--text-muted); }}
|
|
487
|
+
.profile-row .value {{ font-family: 'DM Sans'; font-weight: 500; }}
|
|
488
|
+
.quote-card {{ background: linear-gradient(135deg, var(--gold-bg) 0%, transparent 100%); padding: 32px; border-left: 4px solid var(--gold); border-radius: 0 16px 16px 0; }}
|
|
489
|
+
.quote-card .quote {{ font-family: 'Playfair Display'; font-size: 22px; font-style: italic; line-height: 1.4; }}
|
|
490
|
+
.quote-card .source {{ font-size: 13px; color: var(--text-muted); margin-top: 16px; }}
|
|
491
|
+
|
|
492
|
+
/* PROGRAMMA */
|
|
493
|
+
.programma-grid {{ display: grid; grid-template-columns: repeat(auto-fill, minmax(220px, 1fr)); gap: 16px; }}
|
|
494
|
+
.amb-card {{ background: var(--card); border: 1px solid var(--border); border-radius: 12px; padding: 18px; transition: all 0.3s; }}
|
|
495
|
+
.amb-card:hover {{ transform: translateY(-2px); box-shadow: 0 8px 24px rgba(0,0,0,0.08); }}
|
|
496
|
+
.amb-name {{ font-family: 'DM Sans'; font-weight: 600; color: var(--gold); margin-bottom: 8px; }}
|
|
497
|
+
.amb-area {{ font-size: 24px; font-family: 'DM Sans'; font-weight: 700; }}
|
|
498
|
+
.amb-desc {{ font-size: 12px; color: var(--text-muted); margin-top: 8px; line-height: 1.4; }}
|
|
499
|
+
|
|
500
|
+
/* GALLERY */
|
|
501
|
+
.gallery-grid {{ display: grid; grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); gap: 16px; }}
|
|
502
|
+
.gallery-card {{ background: var(--card); border: 1px solid var(--border); border-radius: 12px; overflow: hidden; }}
|
|
503
|
+
.gallery-card img {{ width: 100%; aspect-ratio: 4/3; object-fit: cover; display: block; }}
|
|
504
|
+
.gallery-card .caption {{ padding: 12px 14px; font-size: 13px; font-family: 'DM Sans'; font-weight: 500; }}
|
|
505
|
+
|
|
506
|
+
/* MOODBOARD */
|
|
507
|
+
.moodboard-section {{ display: grid; grid-template-columns: 2fr 1fr; gap: 24px; align-items: start; }}
|
|
508
|
+
.moodboard-img {{ width: 100%; border-radius: 16px; box-shadow: 0 12px 40px rgba(0,0,0,0.12); }}
|
|
509
|
+
.palette-list {{ display: grid; gap: 12px; }}
|
|
510
|
+
.swatch {{ display: flex; align-items: center; gap: 14px; padding: 12px; background: var(--card); border: 1px solid var(--border); border-radius: 10px; }}
|
|
511
|
+
.swatch-color {{ width: 48px; height: 48px; border-radius: 8px; flex-shrink: 0; box-shadow: inset 0 0 0 1px rgba(0,0,0,0.05); }}
|
|
512
|
+
.swatch-name {{ font-family: 'DM Sans'; font-weight: 600; font-size: 13px; }}
|
|
513
|
+
.swatch-meta {{ font-size: 11px; color: var(--text-muted); margin-top: 2px; }}
|
|
514
|
+
.swatch-usage {{ font-size: 11px; color: var(--text-muted); margin-top: 2px; font-style: italic; }}
|
|
515
|
+
|
|
516
|
+
/* BEFORE/AFTER */
|
|
517
|
+
.ba-grid-wrap {{ display: grid; gap: 24px; }}
|
|
518
|
+
.ba-card {{ background: var(--card); border: 1px solid var(--border); border-radius: 16px; overflow: hidden; }}
|
|
519
|
+
.ba-header {{ padding: 14px 20px; background: linear-gradient(90deg, var(--gold-bg) 0%, transparent 100%); font-family: 'Outfit'; font-weight: 600; font-size: 16px; }}
|
|
520
|
+
.ba-grid {{ display: grid; grid-template-columns: 1fr 1fr; }}
|
|
521
|
+
.ba-side {{ position: relative; }}
|
|
522
|
+
.ba-side img {{ width: 100%; aspect-ratio: 4/3; object-fit: cover; display: block; }}
|
|
523
|
+
.ba-tag {{ position: absolute; top: 12px; left: 12px; padding: 4px 10px; border-radius: 6px; font-family: 'DM Sans'; font-size: 11px; font-weight: 600; letter-spacing: 0.05em; }}
|
|
524
|
+
.tag-before {{ background: rgba(0,0,0,0.7); color: white; }}
|
|
525
|
+
.tag-after {{ background: var(--gold); color: white; }}
|
|
526
|
+
|
|
527
|
+
/* RENDERS */
|
|
528
|
+
.renders-grid {{ display: grid; grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); gap: 16px; }}
|
|
529
|
+
.render-card {{ background: var(--card); border: 1px solid var(--border); border-radius: 12px; overflow: hidden; }}
|
|
530
|
+
.render-card img {{ width: 100%; aspect-ratio: 1; object-fit: cover; display: block; }}
|
|
531
|
+
.render-card .caption {{ padding: 12px 14px; font-family: 'DM Sans'; font-weight: 500; font-size: 14px; }}
|
|
532
|
+
|
|
533
|
+
/* BUDGET */
|
|
534
|
+
.budget-card {{ background: var(--card); border: 1px solid var(--border); border-radius: 16px; padding: 32px; }}
|
|
535
|
+
.budget-total {{ font-family: 'Playfair Display'; font-size: 48px; font-weight: 700; color: var(--gold); }}
|
|
536
|
+
.budget-total-label {{ color: var(--text-muted); font-size: 14px; margin-bottom: 24px; }}
|
|
537
|
+
.bar-row {{ display: grid; grid-template-columns: 200px 1fr; gap: 16px; padding: 10px 0; align-items: center; }}
|
|
538
|
+
.bar-label {{ font-family: 'DM Sans'; font-size: 14px; }}
|
|
539
|
+
.bar-track {{ background: var(--bg); border-radius: 6px; height: 32px; position: relative; overflow: hidden; }}
|
|
540
|
+
.bar-fill {{ height: 100%; border-radius: 6px; transition: width 0.6s ease; }}
|
|
541
|
+
.bar-value {{ position: absolute; right: 12px; top: 50%; transform: translateY(-50%); font-family: 'DM Sans'; font-weight: 600; font-size: 13px; }}
|
|
542
|
+
|
|
543
|
+
/* TIMELINE */
|
|
544
|
+
.timeline-card {{ background: var(--card); border: 1px solid var(--border); border-radius: 16px; padding: 32px; }}
|
|
545
|
+
.tl-row {{ display: grid; grid-template-columns: 200px 1fr; gap: 16px; padding: 8px 0; align-items: center; }}
|
|
546
|
+
.tl-label {{ font-family: 'DM Sans'; font-size: 13px; }}
|
|
547
|
+
.tl-bar {{ height: 24px; border-radius: 4px; padding: 0 12px; display: flex; align-items: center; color: white; font-family: 'DM Sans'; font-size: 11px; font-weight: 500; }}
|
|
548
|
+
.sal-grid {{ display: grid; grid-template-columns: repeat(2, 1fr); gap: 16px; margin-top: 32px; }}
|
|
549
|
+
.sal-row {{ background: var(--bg); padding: 16px; border-radius: 10px; display: flex; align-items: center; gap: 16px; }}
|
|
550
|
+
.sal-marker {{ width: 56px; height: 56px; border-radius: 50%; background: var(--gold); color: white; display: flex; align-items: center; justify-content: center; font-family: 'Playfair Display'; font-weight: 700; font-size: 18px; flex-shrink: 0; }}
|
|
551
|
+
|
|
552
|
+
/* DOCS */
|
|
553
|
+
.docs-grid {{ display: grid; grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); gap: 16px; }}
|
|
554
|
+
.cat-card {{ background: var(--card); border: 1px solid var(--border); border-radius: 12px; overflow: hidden; }}
|
|
555
|
+
.cat-header {{ padding: 16px 20px; display: flex; align-items: center; gap: 12px; border-bottom: 1px solid var(--border); }}
|
|
556
|
+
.cat-icon {{ font-size: 20px; }}
|
|
557
|
+
.cat-header h3 {{ font-family: 'Outfit'; font-weight: 600; font-size: 15px; margin: 0; flex: 1; }}
|
|
558
|
+
.cat-count {{ font-family: 'DM Sans'; background: var(--bg); padding: 2px 10px; border-radius: 999px; font-size: 12px; font-weight: 600; }}
|
|
559
|
+
.cat-items {{ padding: 8px; }}
|
|
560
|
+
.doc-item {{ display: flex; justify-content: space-between; padding: 8px 12px; border-radius: 6px; font-size: 13px; text-decoration: none; color: var(--text); transition: background 0.2s; }}
|
|
561
|
+
.doc-item:hover {{ background: var(--bg); }}
|
|
562
|
+
.doc-item .doc-name {{ font-family: 'DM Sans'; }}
|
|
563
|
+
.doc-item .doc-meta {{ color: var(--text-muted); font-family: 'DM Sans'; font-size: 11px; }}
|
|
564
|
+
|
|
565
|
+
/* IMPRESE */
|
|
566
|
+
.imp-grid {{ display: grid; grid-template-columns: repeat(3, 1fr); gap: 16px; }}
|
|
567
|
+
.imp-card {{ background: var(--card); border: 1px solid var(--border); border-radius: 12px; padding: 24px; }}
|
|
568
|
+
.imp-name {{ font-family: 'DM Sans'; font-weight: 700; margin-bottom: 8px; }}
|
|
569
|
+
.imp-meta {{ font-size: 13px; color: var(--text-muted); }}
|
|
570
|
+
|
|
571
|
+
/* FOOTER */
|
|
572
|
+
footer {{ background: var(--text); color: white; padding: 60px 40px; margin-top: 60px; }}
|
|
573
|
+
footer .inner {{ max-width: 1200px; margin: 0 auto; }}
|
|
574
|
+
footer h3 {{ font-family: 'Playfair Display'; font-size: 28px; margin: 0 0 16px; }}
|
|
575
|
+
footer .links {{ display: flex; gap: 24px; flex-wrap: wrap; margin-top: 24px; opacity: 0.75; font-size: 13px; }}
|
|
576
|
+
|
|
577
|
+
@media (max-width: 768px) {{
|
|
578
|
+
.hero h1 {{ font-size: 40px; }}
|
|
579
|
+
.hero-stats {{ grid-template-columns: repeat(2, 1fr); }}
|
|
580
|
+
.profile-grid, .moodboard-section {{ grid-template-columns: 1fr; }}
|
|
581
|
+
.imp-grid {{ grid-template-columns: 1fr; }}
|
|
582
|
+
.ba-grid {{ grid-template-columns: 1fr; }}
|
|
583
|
+
.sal-grid {{ grid-template-columns: 1fr; }}
|
|
584
|
+
}}
|
|
585
|
+
</style>
|
|
586
|
+
</head><body>
|
|
587
|
+
|
|
588
|
+
<section class="hero">
|
|
589
|
+
<div class="hero-inner">
|
|
590
|
+
<div class="chip">📍 Milano · Brera · 25 Aprile 2026</div>
|
|
591
|
+
<h1>Attico <em>Brera</em></h1>
|
|
592
|
+
<div class="subtitle">Marco Rossini & Giulia Bianchi · Via Fiori Chiari 17, 20121 Milano</div>
|
|
593
|
+
<div class="hero-stats">
|
|
594
|
+
<div class="stat-card"><div class="stat-num">120</div><div class="stat-label">m² · superficie lorda</div></div>
|
|
595
|
+
<div class="stat-card"><div class="stat-num">€180K</div><div class="stat-label">budget lavori (IVA inclusa)</div></div>
|
|
596
|
+
<div class="stat-card"><div class="stat-num">90</div><div class="stat-label">giorni cantiere</div></div>
|
|
597
|
+
<div class="stat-card"><div class="stat-num">9</div><div class="stat-label">ambienti programma spaziale</div></div>
|
|
598
|
+
</div>
|
|
599
|
+
</div>
|
|
600
|
+
</section>
|
|
601
|
+
|
|
602
|
+
<section class="block" id="cliente">
|
|
603
|
+
<div class="sec-title">
|
|
604
|
+
<span class="sec-num">01 · CLIENTE</span>
|
|
605
|
+
<h2>Famiglia Rossini-Bianchi</h2>
|
|
606
|
+
</div>
|
|
607
|
+
<div class="profile-grid">
|
|
608
|
+
<div class="profile-card">
|
|
609
|
+
<div class="profile-avatar">MR</div>
|
|
610
|
+
<div style="font-family: 'Playfair Display'; font-size: 22px; font-weight: 600;">Marco & Giulia</div>
|
|
611
|
+
<div class="muted" style="font-size: 13px; margin-bottom: 16px;">+ Sofia (6) · Otto Labrador</div>
|
|
612
|
+
<div class="profile-row"><span class="label">Marco</span><span class="value">42 · Avvocato d'affari</span></div>
|
|
613
|
+
<div class="profile-row"><span class="label">Giulia</span><span class="value">38 · Designer gioielli</span></div>
|
|
614
|
+
<div class="profile-row"><span class="label">Reddito</span><span class="value">€180K/anno</span></div>
|
|
615
|
+
<div class="profile-row"><span class="label">PEC</span><span class="value" style="font-size: 11px;">rossini.bianchi@pec.it</span></div>
|
|
616
|
+
<div class="profile-row"><span class="label">Stato</span><span class="value" style="color: var(--emerald);">✓ Contratto firmato</span></div>
|
|
617
|
+
</div>
|
|
618
|
+
<div class="quote-card">
|
|
619
|
+
<p class="quote">"Vogliamo uno spazio che respira. La cucina deve essere il centro — cuciniamo tutti i giorni, riceviamo amici. Sofia ha bisogno di una stanza sua che cresca con lei. Amiamo il legno chiaro, il travertino, le tinte terra. <strong>Non vogliamo il total white milanese.</strong>"</p>
|
|
620
|
+
<div class="source">— Marco Rossini · riunione 22/04/2026</div>
|
|
621
|
+
</div>
|
|
622
|
+
</div>
|
|
623
|
+
</section>
|
|
624
|
+
|
|
625
|
+
<section class="block" id="programma">
|
|
626
|
+
<div class="sec-title">
|
|
627
|
+
<span class="sec-num">02 · PROGRAMMA SPAZIALE</span>
|
|
628
|
+
<h2>9 ambienti · 120 m²</h2>
|
|
629
|
+
</div>
|
|
630
|
+
<div class="programma-grid">
|
|
631
|
+
<div class="amb-card"><div class="amb-name">Living open-space</div><div class="amb-area">47.5 m²</div><div class="amb-desc">Soggiorno + cucina + dining · isola centrale · seminato preservato</div></div>
|
|
632
|
+
<div class="amb-card"><div class="amb-name">Camera padronale</div><div class="amb-area">18 m²</div><div class="amb-desc">Letto king + cabina armadio walk-in · soffitto decorato originale</div></div>
|
|
633
|
+
<div class="amb-card"><div class="amb-name">Studio Marco</div><div class="amb-area">13.5 m²</div><div class="amb-desc">Vetrata interna · libreria · porta scorrevole acustica</div></div>
|
|
634
|
+
<div class="amb-card"><div class="amb-name">Camera Sofia</div><div class="amb-area">12 m²</div><div class="amb-desc">Carta da parati botanica · scrivania · materiali sani</div></div>
|
|
635
|
+
<div class="amb-card"><div class="amb-name">Bagno padronale</div><div class="amb-area">7 m²</div><div class="amb-desc">Spa-like · gres travertino · doccia walk-in · cromoterapia</div></div>
|
|
636
|
+
<div class="amb-card"><div class="amb-name">Bagno secondo</div><div class="amb-area">5.5 m²</div><div class="amb-desc">Vasca · lavabo · WC · per Sofia + ospiti</div></div>
|
|
637
|
+
<div class="amb-card"><div class="amb-name">Ingresso</div><div class="amb-area">6 m²</div><div class="amb-desc">Funzionale · scarpe + cappotti · custom oak</div></div>
|
|
638
|
+
<div class="amb-card"><div class="amb-name">Lavanderia</div><div class="amb-area">3.5 m²</div><div class="amb-desc">Lavatrice + asciugatrice + spazio Otto</div></div>
|
|
639
|
+
<div class="amb-card"><div class="amb-name">Terrazzo</div><div class="amb-area">20 m²</div><div class="amb-desc">SW · BBQ · accessibile da living E camera padronale</div></div>
|
|
640
|
+
</div>
|
|
641
|
+
</section>
|
|
642
|
+
|
|
643
|
+
<section class="block" id="stato-attuale">
|
|
644
|
+
<div class="sec-title">
|
|
645
|
+
<span class="sec-num">03 · STATO ATTUALE</span>
|
|
646
|
+
<h2>Foto degli ambienti · prima della ristrutturazione</h2>
|
|
647
|
+
</div>
|
|
648
|
+
<p class="sec-intro">Edificio del 1910 con ristrutturazione anni '80 non funzionale. Da preservare: soffitti decorati originali e seminato veneziano nel soggiorno.</p>
|
|
649
|
+
<div class="gallery-grid">{foto_cards}</div>
|
|
650
|
+
</section>
|
|
651
|
+
|
|
652
|
+
<section class="block" id="references">
|
|
653
|
+
<div class="sec-title">
|
|
654
|
+
<span class="sec-num">04 · STILE PREFERENZIALE</span>
|
|
655
|
+
<h2>References del cliente</h2>
|
|
656
|
+
</div>
|
|
657
|
+
<p class="sec-intro">12 immagini Pinterest selezionate dal cliente: Vincenzo De Cotiis, Studiopepe, Marcante & Testa, Hotel Vilòn, Cassina, Devon&Devon. Hashtag: #materialhonesty #wabisabi #neoclassicocontemporaneo</p>
|
|
658
|
+
<div class="gallery-grid">{pin_cards}</div>
|
|
659
|
+
</section>
|
|
660
|
+
|
|
661
|
+
<section class="block" id="moodboard">
|
|
662
|
+
<div class="sec-title">
|
|
663
|
+
<span class="sec-num">05 · CONCEPT</span>
|
|
664
|
+
<h2>Moodboard + paleta colori</h2>
|
|
665
|
+
</div>
|
|
666
|
+
<p class="sec-intro">Composizione editoriale di materiali, finiture, paleta cromatica · base concept progettuale.</p>
|
|
667
|
+
<div class="moodboard-section">
|
|
668
|
+
<img src="{moodboard_url}" alt="Moodboard composto" class="moodboard-img"/>
|
|
669
|
+
<div class="palette-list">{palette_swatches}</div>
|
|
670
|
+
</div>
|
|
671
|
+
</section>
|
|
672
|
+
|
|
673
|
+
<section class="block" id="before-after" style="background: linear-gradient(135deg, var(--gold-bg) 0%, transparent 100%); border-radius: 24px; max-width: 1280px;">
|
|
674
|
+
<div class="sec-title">
|
|
675
|
+
<span class="sec-num">06 · BEFORE / AFTER</span>
|
|
676
|
+
<h2>Lo spazio · prima e dopo</h2>
|
|
677
|
+
</div>
|
|
678
|
+
<p class="sec-intro">Render generati con AI image-to-image · <strong>preservando la struttura originale</strong> (muri, finestre, soffitti decorati) e aggiungendo lo stile post-ristrutturazione.</p>
|
|
679
|
+
<div class="ba-grid-wrap">{ba_cards}</div>
|
|
680
|
+
</section>
|
|
681
|
+
|
|
682
|
+
<section class="block" id="render">
|
|
683
|
+
<div class="sec-title">
|
|
684
|
+
<span class="sec-num">07 · RENDER PROGETTO</span>
|
|
685
|
+
<h2>5 ambienti · post-ristrutturazione</h2>
|
|
686
|
+
</div>
|
|
687
|
+
<div class="renders-grid">{render_cards}</div>
|
|
688
|
+
</section>
|
|
689
|
+
|
|
690
|
+
<section class="block" id="budget">
|
|
691
|
+
<div class="sec-title">
|
|
692
|
+
<span class="sec-num">08 · BUDGET</span>
|
|
693
|
+
<h2>€180K · breakdown per categoria</h2>
|
|
694
|
+
</div>
|
|
695
|
+
<div class="budget-card">
|
|
696
|
+
<div class="budget-total">€ 180.000</div>
|
|
697
|
+
<div class="budget-total-label">Totale lavori · IVA 10% prima casa inclusa · Onorari professionali separati € 22.000 (12,2%)</div>
|
|
698
|
+
{budget_bars}
|
|
699
|
+
</div>
|
|
700
|
+
</section>
|
|
701
|
+
|
|
702
|
+
<section class="block" id="timeline">
|
|
703
|
+
<div class="sec-title">
|
|
704
|
+
<span class="sec-num">09 · TIMELINE</span>
|
|
705
|
+
<h2>90 giorni cantiere · 1 lug → 31 ott 2026</h2>
|
|
706
|
+
</div>
|
|
707
|
+
<div class="timeline-card">
|
|
708
|
+
<div style="margin-bottom: 24px;">
|
|
709
|
+
<div style="font-family: 'Outfit'; font-weight: 600; margin-bottom: 12px;">Cronoprogramma per fase</div>
|
|
710
|
+
{timeline_rows}
|
|
711
|
+
</div>
|
|
712
|
+
<div>
|
|
713
|
+
<div style="font-family: 'Outfit'; font-weight: 600; margin-top: 32px; margin-bottom: 12px;">SAL milestones · pagamenti onorari</div>
|
|
714
|
+
<div class="sal-grid">{sal_milestones}</div>
|
|
715
|
+
</div>
|
|
716
|
+
</div>
|
|
717
|
+
</section>
|
|
718
|
+
|
|
719
|
+
<section class="block" id="dossier">
|
|
720
|
+
<div class="sec-title">
|
|
721
|
+
<span class="sec-num">10 · DOSSIER</span>
|
|
722
|
+
<h2>Tutti i deliverables · per categoria</h2>
|
|
723
|
+
</div>
|
|
724
|
+
<p class="sec-intro">Pacchetto completo generato dallo squad architettura-progetto · pronto per firma del professionista, deposito al Comune, gara impresa.</p>
|
|
725
|
+
<div class="docs-grid">{cat_sections}</div>
|
|
726
|
+
</section>
|
|
727
|
+
|
|
728
|
+
<section class="block" id="imprese">
|
|
729
|
+
<div class="sec-title">
|
|
730
|
+
<span class="sec-num">11 · IMPRESE</span>
|
|
731
|
+
<h2>3 imprese pre-selezionate dal cliente</h2>
|
|
732
|
+
</div>
|
|
733
|
+
<div class="imp-grid">
|
|
734
|
+
<div class="imp-card">
|
|
735
|
+
<div class="imp-name">Edilcasa Lombardia Srl</div>
|
|
736
|
+
<div class="imp-meta">📍 Buccinasco<br>Esperienza Brera · raccomandata da amico avvocato</div>
|
|
737
|
+
</div>
|
|
738
|
+
<div class="imp-card">
|
|
739
|
+
<div class="imp-name">Costruzioni Galimberti</div>
|
|
740
|
+
<div class="imp-meta">📍 Milano · Via Marghera<br>Ha rifatto l'appartamento dei genitori di Giulia</div>
|
|
741
|
+
</div>
|
|
742
|
+
<div class="imp-card">
|
|
743
|
+
<div class="imp-name">Restauri Brambilla & Co.</div>
|
|
744
|
+
<div class="imp-meta">📍 Cernusco<br>Specialisti edifici storici · più caro · garanzia restauro</div>
|
|
745
|
+
</div>
|
|
746
|
+
</div>
|
|
747
|
+
</section>
|
|
748
|
+
|
|
749
|
+
<section class="block" id="studio" style="background: linear-gradient(180deg, var(--bg) 0%, white 100%); border-radius: 24px;">
|
|
750
|
+
<div class="sec-title">
|
|
751
|
+
<span class="sec-num">12 · LO STUDIO</span>
|
|
752
|
+
<h2>{arch_company_h or arch_name_h}</h2>
|
|
753
|
+
</div>
|
|
754
|
+
{f'<img src="{arch_logo}" alt="{arch_company_h} logo" style="max-height: 80px; margin-bottom: 24px;"/>' if arch_logo else ''}
|
|
755
|
+
<div class="profile-grid">
|
|
756
|
+
<div class="profile-card">
|
|
757
|
+
<div class="profile-avatar">{arch_name_h[:2].upper() if arch_name_h else 'AR'}</div>
|
|
758
|
+
<div style="font-family: 'Playfair Display'; font-size: 22px; font-weight: 600;">Arch. {arch_name_h}</div>
|
|
759
|
+
<div class="muted" style="font-size: 13px; margin-bottom: 16px;">{profile.get("position", "Architetto")}</div>
|
|
760
|
+
<div class="profile-row"><span class="label">Studio</span><span class="value">{arch_company_h}</span></div>
|
|
761
|
+
<div class="profile-row"><span class="label">Ordine</span><span class="value">{arch_ordine_h}</span></div>
|
|
762
|
+
{f'<div class="profile-row"><span class="label">Matricola</span><span class="value">n. {arch_matricola_h}</span></div>' if arch_matricola_h else ''}
|
|
763
|
+
<div class="profile-row"><span class="label">Sede</span><span class="value" style="font-size: 12px;">{arch_address_h}</span></div>
|
|
764
|
+
{f'<div class="profile-row"><span class="label">Email</span><span class="value" style="font-size: 12px;">{arch_email_h}</span></div>' if arch_email_h else ''}
|
|
765
|
+
{f'<div class="profile-row"><span class="label">Tel</span><span class="value">{arch_phone_h}</span></div>' if arch_phone_h else ''}
|
|
766
|
+
{f'<div class="profile-row"><span class="label">PEC</span><span class="value" style="font-size: 11px;">{arch_pec_h}</span></div>' if arch_pec_h else ''}
|
|
767
|
+
</div>
|
|
768
|
+
<div>
|
|
769
|
+
{f'<div class="quote-card" style="margin-bottom: 16px;"><p class="quote" style="font-size: 18px;">{brand.get("mission", "")[:400]}</p><div class="source">— Mission · {brand.get("name", arch_company_h)}</div></div>' if brand.get("mission") else ''}
|
|
770
|
+
{f'<div class="quote-card" style="margin-bottom: 16px; background: linear-gradient(135deg, rgba(135,164,122,0.08) 0%, transparent 100%); border-left-color: #87A47A;"><p class="quote" style="font-size: 16px;">{brand.get("vision", "")[:400]}</p><div class="source">— Vision</div></div>' if brand.get("vision") else ''}
|
|
771
|
+
{f'<div style="background: var(--card); padding: 20px; border-radius: 12px; border: 1px solid var(--border);"><div style="font-family: Outfit; font-weight: 600; margin-bottom: 12px;">Valori dello studio</div><div style="font-size: 13px; line-height: 1.7; color: var(--text-muted); white-space: pre-line;">{brand.get("values", "")}</div></div>' if brand.get("values") else ''}
|
|
772
|
+
</div>
|
|
773
|
+
</div>
|
|
774
|
+
|
|
775
|
+
{('<div style="margin-top: 32px;"><div style="font-family: Outfit; font-weight: 600; margin-bottom: 12px;">Paleta della marca</div><div style="display: flex; gap: 12px; flex-wrap: wrap;">' +
|
|
776
|
+
"".join(f'<div style="text-align: center;"><div style="width: 60px; height: 60px; border-radius: 8px; background: {c}; box-shadow: inset 0 0 0 1px rgba(0,0,0,0.05); margin-bottom: 6px;"></div><div style="font-size: 11px; font-family: DM Sans; color: var(--text-muted);">{c}</div></div>'
|
|
777
|
+
for c in (brand.get("palette") or [])) + '</div></div>') if brand.get("palette") else ''}
|
|
778
|
+
|
|
779
|
+
{f'<div style="margin-top: 32px;"><div style="font-family: Outfit; font-weight: 600; margin-bottom: 12px;">Stile architettonico dello studio</div><div style="background: var(--card); padding: 16px; border-radius: 10px; border: 1px solid var(--border);"><strong>{style_data.get("style_name", "")}</strong><div style="font-size: 13px; color: var(--text-muted); margin-top: 6px;">Keywords: {", ".join(style_data.get("keywords", []))}</div><div style="font-size: 13px; color: var(--text-muted); margin-top: 4px;">Materiali firma: {", ".join(style_data.get("materials", []))}</div></div></div>' if style_data.get("style_name") else ''}
|
|
780
|
+
</section>
|
|
781
|
+
|
|
782
|
+
<footer>
|
|
783
|
+
<div class="inner">
|
|
784
|
+
<h3>Arch. {arch_name_h}{f" · {arch_company_h}" if arch_company_h else ""}</h3>
|
|
785
|
+
<p style="opacity: 0.85;">{arch_ordine_h}{f" · n. matr. {arch_matricola_h}" if arch_matricola_h else ""}{f" · {arch_address_h}" if arch_address_h else ""}</p>
|
|
786
|
+
<p style="opacity: 0.75; font-size: 13px;">{arch_email_h}{f" · {arch_phone_h}" if arch_phone_h else ""}{f" · PEC {arch_pec_h}" if arch_pec_h else ""}</p>
|
|
787
|
+
<div class="links">
|
|
788
|
+
<span>Contratto firmato 24/04/2026</span>
|
|
789
|
+
<span>Onorari € 22.000 · 4 SAL</span>
|
|
790
|
+
<span>Generato dallo squad architettura-progetto v2.0</span>
|
|
791
|
+
<span>Lovarch Platform · 25/04/2026</span>
|
|
792
|
+
</div>
|
|
793
|
+
</div>
|
|
794
|
+
</footer>
|
|
795
|
+
|
|
796
|
+
</body></html>"""
|
|
797
|
+
|
|
798
|
+
|
|
799
|
+
# ============================================================================
|
|
800
|
+
# JSON · text/plain (bucket doesn't allow application/json)
|
|
801
|
+
# ============================================================================
|
|
802
|
+
|
|
803
|
+
def gen_json_pretty(data: Any) -> bytes:
|
|
804
|
+
return json.dumps(data, indent=2, ensure_ascii=False).encode("utf-8")
|
|
805
|
+
|
|
806
|
+
|
|
807
|
+
# ============================================================================
|
|
808
|
+
# ZIP · bundle DOSSIER-IMPRESA.zip
|
|
809
|
+
# ============================================================================
|
|
810
|
+
|
|
811
|
+
def gen_zip_dossier(files: List[Tuple[str, bytes]]) -> bytes:
|
|
812
|
+
"""Bundle multiple files into a zip · for impresa."""
|
|
813
|
+
buf = BytesIO()
|
|
814
|
+
with zipfile.ZipFile(buf, "w", zipfile.ZIP_DEFLATED) as z:
|
|
815
|
+
for name, content in files:
|
|
816
|
+
z.writestr(name, content)
|
|
817
|
+
return buf.getvalue()
|
|
818
|
+
|
|
819
|
+
|
|
820
|
+
# ============================================================================
|
|
821
|
+
# MIME types map · matches user-assets bucket allowlist
|
|
822
|
+
# ============================================================================
|
|
823
|
+
|
|
824
|
+
MIME_BY_EXT = {
|
|
825
|
+
".pdf": "application/pdf",
|
|
826
|
+
".dxf": "application/acad",
|
|
827
|
+
".dwg": "application/dwg",
|
|
828
|
+
".xlsx": "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
|
829
|
+
".docx": "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
|
|
830
|
+
".zip": "application/zip",
|
|
831
|
+
".json": "application/json",
|
|
832
|
+
".html": "text/html",
|
|
833
|
+
".htm": "text/html",
|
|
834
|
+
".txt": "text/plain",
|
|
835
|
+
".csv": "text/csv",
|
|
836
|
+
".png": "image/png",
|
|
837
|
+
".jpg": "image/jpeg",
|
|
838
|
+
".jpeg": "image/jpeg",
|
|
839
|
+
}
|
|
840
|
+
|
|
841
|
+
|
|
842
|
+
def mime_for(filename: str) -> str:
|
|
843
|
+
ext = "." + filename.rsplit(".", 1)[-1].lower() if "." in filename else ""
|
|
844
|
+
return MIME_BY_EXT.get(ext, "application/octet-stream")
|