Gi rapporten plass til kildene, og la den følge egne dokumenter
Release enok / bygg (release) Successful in 1m12s
test / test (push) Successful in 1m16s

Kolonnen «Kilde» i forutsetningstabellen fikk for liten plass, fordi de to første kolonnene
tok det de ville. Kolonnene har nå faste bredder, og tabellen leses på én linje per rad.

En ny [rapport]-tabell i bygningsbeskrivelsen setter logo, aksentfarge, avsenderlinje og
skrift for overskrifter, slik at energirapporten kan se ut som firmaets øvrige rapporter.
Logoen kopieres til utdatakatalogen; mangler filen, lages rapporten uten den.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015GeH3y8fwTaTudaD88P57o
This commit is contained in:
bl
2026-09-20 22:02:36 +02:00
co-authored by Claude Opus 5
parent e3f37afe36
commit c89561b0f5
9 changed files with 118 additions and 16 deletions
Generated
+1 -1
View File
@@ -222,7 +222,7 @@ dependencies = [
[[package]] [[package]]
name = "enok" name = "enok"
version = "0.4.1" version = "0.4.2"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"approx", "approx",
+1 -1
View File
@@ -1,6 +1,6 @@
[package] [package]
name = "enok" name = "enok"
version = "0.4.1" version = "0.4.2"
edition = "2021" edition = "2021"
description = "Årlig energiberegning og energirapport for boliger etter NS 3031 (timesmetode, ISO 52016-1)" description = "Årlig energiberegning og energirapport for boliger etter NS 3031 (timesmetode, ISO 52016-1)"
license = "MIT" license = "MIT"
+10
View File
@@ -180,6 +180,16 @@ navn = "Nye vinduer"
endringer.vindu = [{ navn = "Vinduer sør", u_verdi = 0.80, g_verdi = 0.50 }] endringer.vindu = [{ navn = "Vinduer sør", u_verdi = 0.80, g_verdi = 0.50 }]
``` ```
Rapporten kan følge husets eller firmaets egne dokumenter:
```toml
[rapport]
logo = "logo.svg" # vises øverst på forsiden
farge = "#880000" # aksentfarge i overskrifter og tabeller
avsender = "Firma · Adresse · Org.nr. 000 000 000"
skrift_overskrift = "Futura Now Headline"
```
Scenarier flettes inn i bygningsbeskrivelsen: tabeller flettes rekursivt, elementer i lister Scenarier flettes inn i bygningsbeskrivelsen: tabeller flettes rekursivt, elementer i lister
matches på `navn` eller `type`, nye elementer legges til, og `slett = true` fjerner et element. matches på `navn` eller `type`, nye elementer legges til, og `slett = true` fjerner et element.
Et navn med `*` treffer alle elementer det passer på, for eksempel `navn = "Yttervegg*"`. Et navn med `*` treffer alle elementer det passer på, for eksempel `navn = "Yttervegg*"`.
+1
View File
@@ -596,6 +596,7 @@ fn bygg(
lufting: Lufting::default(), lufting: Lufting::default(),
drift: Drift::default(), drift: Drift::default(),
scenario: Vec::new(), scenario: Vec::new(),
rapport: Rapport::default(),
}; };
Ok(Importrapport { bygning, merknader }) Ok(Importrapport { bygning, merknader })
} }
+1
View File
@@ -833,6 +833,7 @@ pub fn fra_tekst_med(tekst: &str, standardnavn: &str, valg: &Importvalg) -> Resu
lufting: Lufting::default(), lufting: Lufting::default(),
drift: Drift::default(), drift: Drift::default(),
scenario: Vec::new(), scenario: Vec::new(),
rapport: Rapport::default(),
}; };
Ok(Importrapport { Ok(Importrapport {
bygning, bygning,
+1
View File
@@ -215,6 +215,7 @@ pub fn fra_tekst(tekst: &str, standardnavn: &str) -> Result<Importrapport> {
lufting: Lufting::default(), lufting: Lufting::default(),
drift: Drift::default(), drift: Drift::default(),
scenario: Vec::new(), scenario: Vec::new(),
rapport: Rapport::default(),
}; };
Ok(Importrapport { bygning, merknader }) Ok(Importrapport { bygning, merknader })
} }
+20
View File
@@ -82,6 +82,26 @@ pub struct Bygning {
pub drift: Drift, pub drift: Drift,
#[serde(default, skip_serializing_if = "Vec::is_empty")] #[serde(default, skip_serializing_if = "Vec::is_empty")]
pub scenario: Vec<Scenario>, pub scenario: Vec<Scenario>,
#[serde(default)]
pub rapport: Rapport,
}
/// Utseendet på rapporten, slik at den kan følge firmaets øvrige dokumenter.
#[derive(Deserialize, Serialize, Clone, Debug, Default)]
#[serde(deny_unknown_fields)]
pub struct Rapport {
/// Logo på forsiden. Filen kopieres til utdatakatalogen ved siden av rapporten.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub logo: Option<PathBuf>,
/// Aksentfarge, for eksempel «#880000». Utelatt: enoks egen.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub farge: Option<String>,
/// Avsenderlinje nederst på hver side, for eksempel firma og organisasjonsnummer.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub avsender: Option<String>,
/// Skrift for overskrifter. Utelatt: enoks egen.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub skrift_overskrift: Option<String>,
} }
#[derive(Deserialize, Serialize, Clone, Debug)] #[derive(Deserialize, Serialize, Clone, Debug)]
+48 -6
View File
@@ -66,6 +66,12 @@ fn rad(celler: &[String]) -> String {
pub fn skriv(r: &Resultat, katalog: &Path, scenarier: &[(String, Resultat)]) -> Result<()> { pub fn skriv(r: &Resultat, katalog: &Path, scenarier: &[(String, Resultat)]) -> Result<()> {
std::fs::create_dir_all(katalog)?; std::fs::create_dir_all(katalog)?;
std::fs::write(katalog.join("stil.typ"), STIL)?; std::fs::write(katalog.join("stil.typ"), STIL)?;
// Logoen følger med, slik at rapporten kan kompileres for seg selv senere. Mangler
// filen, lages rapporten uten den.
if let (Some(logo), Some(navn)) = (&r.bygning.rapport.logo, logo_navn(r)) {
std::fs::copy(logo, katalog.join(navn))
.with_context(|| format!("kopierer logoen {}", logo.display()))?;
}
std::fs::write(katalog.join("fig-maaned.svg"), svg::maanedsdiagram(r))?; std::fs::write(katalog.join("fig-maaned.svg"), svg::maanedsdiagram(r))?;
std::fs::write(katalog.join("fig-varighet.svg"), svg::varighetskurve(r))?; std::fs::write(katalog.join("fig-varighet.svg"), svg::varighetskurve(r))?;
std::fs::write(katalog.join("fig-karakter.svg"), svg::karakterstige(r))?; std::fs::write(katalog.join("fig-karakter.svg"), svg::karakterstige(r))?;
@@ -100,13 +106,30 @@ fn kilde(r: &Resultat, scenarier: &[(String, Resultat)]) -> String {
let mut s = String::new(); let mut s = String::new();
s.push_str("#import \"stil.typ\": *\n\n"); s.push_str("#import \"stil.typ\": *\n\n");
s.push_str(&format!( let rap = &r.bygning.rapport;
"#show: oppsett.with(tittel: \"{} — energirapport\", bunntekst: \"{}\")\n\n", let mut valg = format!(
e(&l.navn), "tittel: \"{} — energirapport\", bunntekst: \"{}\"",
e(&bunntekst(r)) streng(&l.navn),
)); streng(&bunntekst(r))
);
if let Some(a) = &rap.avsender {
valg.push_str(&format!(", avsender: \"{}\"", streng(a)));
}
if let Some(f) = &rap.farge {
valg.push_str(&format!(", farge: rgb(\"{}\")", streng(f)));
}
if let Some(sk) = &rap.skrift_overskrift {
valg.push_str(&format!(", skrift: \"{}\"", streng(sk)));
}
s.push_str(&format!("#show: oppsett.with({valg})\n\n"));
// ── forside ───────────────────────────────────────────────────── // ── forside ─────────────────────────────────────────────────────
if let Some(navn) = logo_navn(r) {
s.push_str(&format!(
"#align(right, image(\"{}\", height: 1.7cm))\n\n#v(-0.9cm)\n",
streng(&navn)
));
}
s.push_str(&format!( s.push_str(&format!(
"#v(1.2cm)\n#text(size: 26pt, weight: \"bold\")[Energirapport]\n\n\ "#v(1.2cm)\n#text(size: 26pt, weight: \"bold\")[Energirapport]\n\n\
#v(-0.3em)\n#text(size: 15pt, fill: mid)[{}]\n\n", #v(-0.3em)\n#text(size: 15pt, fill: mid)[{}]\n\n",
@@ -473,7 +496,8 @@ fn kilde(r: &Resultat, scenarier: &[(String, Resultat)]) -> String {
if l.forutsetninger.is_empty() { if l.forutsetninger.is_empty() {
s.push_str("Alle inndata er oppgitt i bygningsbeskrivelsen.\n\n"); s.push_str("Alle inndata er oppgitt i bygningsbeskrivelsen.\n\n");
} else { } else {
s.push_str("#tabell(\n kolonner: (auto, auto, 1fr),\n justering: (left, left, left),\n overskrift: (\"Felt\", \"Verdi\", \"Kilde\"),\n rader: (\n"); // Faste bredder, ellers spiser de to første kolonnene plassen kilden trenger.
s.push_str("#tabell(\n kolonner: (1.15fr, 0.85fr, 1.6fr),\n justering: (left, left, left),\n overskrift: (\"Felt\", \"Verdi\", \"Kilde\"),\n rader: (\n");
for f in &l.forutsetninger { for f in &l.forutsetninger {
s.push_str(&format!( s.push_str(&format!(
" {}\n", " {}\n",
@@ -539,6 +563,24 @@ fn kilde(r: &Resultat, scenarier: &[(String, Resultat)]) -> String {
s s
} }
/// Tekst i en Typst-strengliteral: bare omvendt skråstrek og anførselstegn skal ut.
fn streng(s: &str) -> String {
s.replace('\\', "\\\\").replace('"', "\\\"")
}
/// Filnavnet på logoen, når den er oppgitt og finnes.
fn logo_navn(r: &Resultat) -> Option<String> {
let logo = r.bygning.rapport.logo.as_ref()?;
if !logo.exists() {
eprintln!(
" ! rapport.logo: fant ikke {}; rapporten lages uten logo.",
logo.display()
);
return None;
}
Some(logo.file_name()?.to_str()?.to_string())
}
fn bunntekst(r: &Resultat) -> String { fn bunntekst(r: &Resultat) -> String {
let b = &r.bygning.bygning; let b = &r.bygning.bygning;
let mut deler = Vec::new(); let mut deler = Vec::new();
+35 -8
View File
@@ -7,11 +7,32 @@
#let lys = rgb("#f4f1ee") #let lys = rgb("#f4f1ee")
#let kantlinje = rgb("#ddd4cc") #let kantlinje = rgb("#ddd4cc")
// Aksentfarge og overskriftsskrift kan settes av rapporten, slik at den kan følge
// firmaets øvrige dokumenter. Verdiene under er enoks egne.
#let aksent-valgt = state("aksent-valgt", aksent)
#let skrift-valgt = state("skrift-valgt", none)
#let font-heading = ("Libertinus Serif", "DejaVu Serif", "Noto Serif") #let font-heading = ("Libertinus Serif", "DejaVu Serif", "Noto Serif")
#let font-body = ("Libertinus Serif", "DejaVu Serif", "Noto Serif") #let font-body = ("Libertinus Serif", "DejaVu Serif", "Noto Serif")
#let font-tall = ("DejaVu Sans Mono", "Liberation Mono") #let font-tall = ("DejaVu Sans Mono", "Liberation Mono")
#let oppsett(tittel: "", undertittel: "", bunntekst: "", doc) = { /// Skriften rapporten har bedt om, ellers enoks egen.
#let overskriftsskrift() = {
let valgt = skrift-valgt.get()
if valgt == none { font-heading } else { (valgt,) + font-heading }
}
#let oppsett(
tittel: "",
undertittel: "",
bunntekst: "",
avsender: "",
farge: none,
skrift: none,
doc,
) = {
aksent-valgt.update(if farge == none { aksent } else { farge })
skrift-valgt.update(skrift)
set page( set page(
paper: "a4", paper: "a4",
margin: (top: 2.4cm, bottom: 2.4cm, left: 2.2cm, right: 2.2cm), margin: (top: 2.4cm, bottom: 2.4cm, left: 2.2cm, right: 2.2cm),
@@ -20,32 +41,36 @@
grid( grid(
columns: (1fr, auto), columns: (1fr, auto),
align: (left + horizon, right + horizon), align: (left + horizon, right + horizon),
text(font: font-heading, size: 8pt, fill: mid)[#tittel], text(font: overskriftsskrift(), size: 8pt, fill: mid)[#tittel],
text(font: font-body, size: 8pt, fill: mid)[#counter(page).display()], text(font: font-body, size: 8pt, fill: mid)[#counter(page).display()],
) )
line(length: 100%, stroke: 0.4pt + kantlinje) line(length: 100%, stroke: 0.4pt + kantlinje)
} }
}, },
footer: context { footer: context {
if counter(page).get().first() > 1 and bunntekst != "" { if counter(page).get().first() > 1 and (bunntekst != "" or avsender != "") {
line(length: 100%, stroke: 0.4pt + kantlinje) line(length: 100%, stroke: 0.4pt + kantlinje)
v(3pt) v(3pt)
if avsender != "" {
text(font: font-body, size: 7.5pt, fill: mid)[#avsender]
linebreak()
}
text(font: font-body, size: 7.5pt, fill: mid)[#bunntekst] text(font: font-body, size: 7.5pt, fill: mid)[#bunntekst]
} }
}, },
) )
set text(font: font-body, size: 10pt, fill: moerk, lang: "nb") set text(font: font-body, size: 10pt, fill: moerk, lang: "nb")
set par(justify: true, leading: 0.62em) set par(justify: true, leading: 0.62em)
show heading: it => { show heading: it => context {
set text(font: font-heading, fill: aksent) set text(font: overskriftsskrift(), fill: aksent-valgt.get())
block(above: 1.2em, below: 0.6em, it) block(above: 1.2em, below: 0.6em, it)
} }
show heading.where(level: 1): it => { show heading.where(level: 1): it => context {
set text(size: 15pt, weight: "bold") set text(size: 15pt, weight: "bold")
block(above: 1.6em, below: 0.7em)[ block(above: 1.6em, below: 0.7em)[
#it.body #it.body
#v(-0.45em) #v(-0.45em)
#line(length: 100%, stroke: 1pt + aksent) #line(length: 100%, stroke: 1pt + aksent-valgt.get())
] ]
} }
show heading.where(level: 2): set text(size: 11.5pt, weight: "bold") show heading.where(level: 2): set text(size: 11.5pt, weight: "bold")
@@ -55,7 +80,8 @@
} }
// Tabell med stripete rader og linje under overskriften. // Tabell med stripete rader og linje under overskriften.
#let tabell(kolonner: (), overskrift: (), rader: (), justering: none, sum-rader: ()) = { #let tabell(kolonner: (), overskrift: (), rader: (), justering: none, sum-rader: ()) = context {
let aksent = aksent-valgt.get()
let j = if justering == none { kolonner.map(_ => right) } else { justering } let j = if justering == none { kolonner.map(_ => right) } else { justering }
block(breakable: true, table( block(breakable: true, table(
columns: kolonner, columns: kolonner,
@@ -99,3 +125,4 @@
stroke: (left: 2pt + aksent-lys), stroke: (left: 2pt + aksent-lys),
text(size: 9pt)[#innhold], text(size: 9pt)[#innhold],
) )