MEGA-BOQ-WEBAPP/boq_engine/theme.py
MEGA INFRA 301d0fff44 MEGA INFRA BOQ Converter — Candy PDF to consultant Excel
Reads our Candy PDF (deterministically, no OCR or model needed) and populates
the consultant's own workbook using their formulas, column layout and PAYMENT
column. Nothing is ever silently overwritten: every disagreement is reported,
can be settled inside the app, and regenerated.

- extract   Candy text PDF -> tree (x-position column detection)
- template  layout detect, sequence align, write, totals, overrides
- validate  qty x rate and children-vs-subtotal reconciliation
- profiles  per-consultant layout profiles, stored on a volume

Shipped with a Dockerfile + docker-compose for Coolify.
2026-10-01 02:32:13 +02:00

190 lines
7.5 KiB
Python

"""
Design system for the Organic / Natural style — moss, clay, and unbleached paper.
The tokens live in `static/theme.css` as CSS custom properties, NOT here.
That is deliberate: injecting a <style> block through `st.markdown` is fragile
in Streamlit (it duplicates on every rerun, and a single malformed selector
silently kills every rule after it - which happened here once already). A
served stylesheet is idempotent, cacheable, and a designer can open and retune
it without touching Python.
This module only injects the <link> once per session and provides the small
card helper used for findings.
Palette (design system, unchanged):
background #FDFCF8 rice paper foreground #2C2C24 deep loam
primary #5D7052 moss green secondary #C18C5D terracotta
accent #E6DCCD sand muted #F0EBE5 stone
border #DED8CF raw timber destructive #A85448 burnt sienna
Contrast on the paper background: #2C2C24 is 14.5:1 (AAA), moss 6.2:1 (AA),
muted text 4.8:1 (AA).
"""
from __future__ import annotations
import html as _html
from pathlib import Path
import streamlit as st
CSS_PATH = Path(__file__).resolve().parent.parent / "static" / "theme.css"
STYLESHEET_URL = "app/static/theme.css" # diagnostic only; see inject()
_FONT_LINK = (
'<link rel="preconnect" href="https://fonts.googleapis.com">'
'<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>'
'<link rel="stylesheet" href="https://fonts.googleapis.com/css2?'
'family=Fraunces:opsz,wght@9..144,600;9..144,700;9..144,800'
'&family=Nunito:wght@400;500;600;700&display=swap">'
)
def inject() -> None:
"""Attach the webfonts and the stylesheet. Safe to call on every rerun.
Why `st.html` and not `st.markdown`: markdown injection duplicated the
style block on each rerun, and one malformed selector silently killed every
rule after it - the page rendered unstyled with no error anywhere. Serving
the CSS from `static/` was tried too, but Streamlit only serves its own
hashed assets from that route; an arbitrary path falls through to the SPA
shell and returns index.html as text/html. `st.html` inserts the sheet
into the page, is not iframed, and replaces cleanly on rerun.
"""
css = CSS_PATH.read_text(encoding="utf-8")
st.html(_FONT_LINK, unsafe_allow_javascript=False)
st.html(f"<style>{css}</style>", unsafe_allow_javascript=False)
SEVERITY_CSS = {
"decision": "boq-card-decision", # a person has to make a call
"source_gap": "boq-card-gap", # our pdf has no figure; theirs is better
"source_defect": "boq-card-defect", # our pdf printed something corrupt
"resolved": "boq-card-resolved",
"note": "boq-card-note",
}
SEVERITY_SWATCH = {
"decision": ("#8C363B", "needs a decision"),
"source_gap": ("#DF8441", "our PDF has no figure - theirs kept"),
"source_defect": ("#FEA944", "our PDF printed something corrupt"),
"resolved": ("#00CD8A", "settled by the consultant"),
"note": ("#00CD8A", "no action needed"),
}
# --------------------------------------------------------------------------
# icons
# --------------------------------------------------------------------------
# Real inline SVG, not emoji. Emoji render differently on every platform, read
# as emoticons, and cannot be sized or coloured to match the brand. These are
# all 24x24 / 1.75 stroke / round caps so the set looks drawn by one hand.
# Inline rather than a CDN icon font, because this app is meant to work with no
# network - that is the point of keeping it local.
_PATHS = {
"building": (
'<path d="M5 21V4a2 2 0 0 1 2-2h8a2 2 0 0 1 2 2v17"/>'
'<path d="M5 12H3.5A1.5 1.5 0 0 0 2 13.5V19a2 2 0 0 0 2 2h1"/>'
'<path d="M17 9h1.5A1.5 1.5 0 0 1 20 10.5V19a2 2 0 0 1-2 2h-1"/>'
'<path d="M9 6h4M9 10h4M9 14h4M9 18h4"/>'
),
"file": (
'<path d="M14 3H7a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h10a2 2 0 0 0 '
'2-2V8Z"/><path d="M14 3v4a1 1 0 0 0 1 1h4"/>'
'<path d="M9 12h6M9 16h4"/>'
),
"table": (
'<rect x="3" y="4" width="18" height="16" rx="2.5"/>'
'<path d="M3 9.5h18M3 14.5h18M9.5 4v16M15 4v16"/>'
),
"alert": (
'<path d="M10.3 3.9 1.8 18a2 2 0 0 0 1.7 3h17a2 2 0 0 0 1.7-3'
'L13.7 3.9a2 2 0 0 0-3.4 0Z"/><path d="M12 9v4.5"/>'
'<path d="M12 17.2h.01"/>'
),
"calculator": (
'<rect x="4" y="2.5" width="16" height="19" rx="2.5"/>'
'<path d="M8 6.5h8"/>'
'<path d="M8.5 11h.01M12 11h.01M15.5 11h.01M8.5 14.5h.01M12 14.5h.01'
'M15.5 14.5h.01M8.5 18h.01M12 18h.01M15.5 18h.01"/>'
),
"clipboard": (
'<rect x="8" y="2.5" width="8" height="3.5" rx="1.2"/>'
'<path d="M16 4.5h2a2 2 0 0 1 2 2V19a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2'
'V6.5a2 2 0 0 1 2-2h2"/><path d="m9 13.5 2 2 4-4"/>'
),
"download": (
'<path d="M21 15v3.5a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V15"/>'
'<path d="m7.5 10.5 4.5 4.5 4.5-4.5"/><path d="M12 15V3"/>'
),
"refresh": (
'<path d="M20.5 12a8.5 8.5 0 0 1-14.6 5.9L3 15"/>'
'<path d="M3 20v-5h5"/>'
'<path d="M3.5 12A8.5 8.5 0 0 1 18.1 6.1L21 9"/>'
'<path d="M21 4v5h-5"/>'
),
"check": (
'<circle cx="12" cy="12" r="9.25"/>'
'<path d="m8 12.2 2.8 2.8L16.2 9.5"/>'
),
"shield": (
'<path d="M12 21.5s7.5-3.5 7.5-9.5V5.6L12 2.5 4.5 5.6V12c0 6 7.5 9.5 '
'7.5 9.5Z"/><path d="m9 11.8 2.2 2.2 4-4"/>'
),
"info": (
'<circle cx="12" cy="12" r="9.25"/><path d="M12 16.5V11"/>'
'<path d="M12 7.8h.01"/>'
),
"user": (
'<circle cx="12" cy="8" r="3.75"/>'
'<path d="M4.5 20.5a7.5 7.5 0 0 1 15 0"/>'
),
"layers": (
'<path d="m12 2.8 9 4.7-9 4.7-9-4.7Z"/><path d="m3 12.5 9 4.7 9-4.7"/>'
'<path d="m3 17.2 9 4.7 9-4.7"/>'
),
}
def icon(name: str, size: int = 18, cls: str = "") -> str:
"""An inline SVG icon. Uses currentColor, so it picks up the theme."""
body = _PATHS.get(name, "")
if not body:
return ""
return (
f'<svg class="boq-icon {cls}" width="{size}" height="{size}" '
f'viewBox="0 0 24 24" fill="none" stroke="currentColor" '
f'stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" '
f'aria-hidden="true" focusable="false">{body}</svg>'
)
def pill(text: str, icon_name: str = "", tone: str = "muted") -> str:
"""A quiet inline note, used in place of a full alert block.
st.warning paints a filled bar and shouts. This is background information
the reader needs once, so it belongs at the volume the legend sits at.
"""
return (
f'<div class="boq-pill boq-pill-{tone}">'
f"{icon(icon_name, 15)}<span>{text}</span></div>"
)
def card(title: str, body: str, meta: str = "", severity: str = "note") -> str:
"""One finding as a card. Returns HTML so long sentences wrap naturally.
Streamlit's dataframe has no text-wrapping option - `column_config` in
1.64 offers width but not wrap - and findings are prose, not figures. A
truncated table cell is exactly the failure this avoids.
"""
cls = SEVERITY_CSS.get(severity, "boq-card-note")
meta_html = (f'<div class="boq-card-meta">{_html.escape(meta)}</div>'
if meta else "")
return (
f'<div class="boq-card {cls}">'
f"{meta_html}"
f'<div class="boq-card-title">{_html.escape(title)}</div>'
f'<div class="boq-card-body">{_html.escape(body)}</div>'
f"</div>"
)