diff --git a/lib/design.js b/lib/design.js index 45ebcae..24d037d 100644 --- a/lib/design.js +++ b/lib/design.js @@ -1,24 +1,41 @@ // Editable design settings for the generated application documents. // // Same contract as lib/prompts.js: the defaults live here, an override is a row -// in the `design` table, and deleting the row restores the default. What stays -// in code is the *layout* — the sidebar grid, the DIN-5008 letter, the one-page -// auto-scaling. What the user picks is a narrow, curated set of knobs. +// in the `design` table, and deleting the row restores the default. // -// The palette is deliberately closed. A free colour picker invites a neon CV, -// which makes an application worse, not better; every option here is a muted -// tone that survives being printed in greyscale by an HR department. +// Two layouts ship today: +// +// sidebar — the classic: neutral greys, tinted sidebar, DIN-5008 letter. The +// accent is a single restrained colour; hierarchy comes from weight +// and tracking. Right for almost every office application. +// +// social — a playful media-kit look (rounded cards, pills, hearts, a script +// headline, a full-page tinted background). Right for creative, +// social-media and design-adjacent roles — and deliberately loud. +// +// The cover letter always follows the résumé's layout, so both documents arrive +// as one coherent set — that is the whole point of picking a design here. +// +// The palette stays closed (no free colour picker): every tone is one that still +// works when an HR department prints it in greyscale. -// Accent colours. `ink`/`sub`/`hair` stay neutral in every theme — the accent -// only carries the name, section labels, company names and markers, exactly as -// the monochrome original did. +// Accent colours. In `sidebar` the accent is the *only* colour; in `social` the +// whole page palette (background, cards, pills) is derived from it, so the +// playful layout works in pink as well as in deep blue. const AKZENTE = [ - { key: 'anthrazit', label: 'Anthrazit (Standard)', rgb: [23, 23, 23] }, + { key: 'anthrazit', label: 'Anthrazit', rgb: [23, 23, 23] }, { key: 'tiefblau', label: 'Tiefblau', rgb: [30, 58, 95] }, { key: 'bordeaux', label: 'Bordeaux', rgb: [122, 31, 47] }, { key: 'waldgruen', label: 'Waldgrün', rgb: [27, 67, 50] }, { key: 'stahlblau', label: 'Stahlblau', rgb: [51, 65, 85] }, { key: 'kupfer', label: 'Kupfer', rgb: [124, 62, 24] }, + { key: 'pink', label: 'Pink (verspielt)', rgb: [232, 54, 143] }, + { key: 'violett', label: 'Violett (verspielt)', rgb: [147, 51, 187] }, +]; + +const LAYOUTS = [ + { key: 'sidebar', label: 'Klassisch mit Sidebar (Standard)' }, + { key: 'social', label: 'Social Media (verspielt)' }, ]; const SIDEBAR_VARIANTEN = [ @@ -28,7 +45,7 @@ const SIDEBAR_VARIANTEN = [ ]; const FOTO_FORMEN = [ - { key: 'eckig', label: 'Eckig (Standard)' }, + { key: 'eckig', label: 'Eckig' }, { key: 'rund', label: 'Rund' }, ]; @@ -40,11 +57,13 @@ const SCHRIFT_STUFEN = [ { key: '110', label: '110 % (groß)' }, ]; -// Layout is fixed for now; the field exists so a second variant can be added -// later without another migration. -const LAYOUTS = [ - { key: 'sidebar', label: 'Sidebar (Standard)' }, -]; +// Defaults per layout: the playful layout is pink and round by default, because +// anthracite-and-square would defeat the point of choosing it. Any of these can +// still be overridden individually. +const LAYOUT_DEFAULTS = { + sidebar: { akzent: 'anthrazit', foto_form: 'eckig' }, + social: { akzent: 'pink', foto_form: 'rund' }, +}; const DEFAULTS = { layout: 'sidebar', @@ -58,7 +77,7 @@ const DEFAULTS = { const FELDER = [ { key: 'layout', label: 'Layout', optionen: LAYOUTS }, { key: 'akzent', label: 'Akzentfarbe', optionen: AKZENTE }, - { key: 'sidebar', label: 'Sidebar-Hintergrund', optionen: SIDEBAR_VARIANTEN }, + { key: 'sidebar', label: 'Sidebar-Hintergrund', optionen: SIDEBAR_VARIANTEN, nurLayout: 'sidebar' }, { key: 'foto_form', label: 'Form des Bewerberfotos', optionen: FOTO_FORMEN }, { key: 'schrift', label: 'Schriftgröße', optionen: SCHRIFT_STUFEN }, ]; @@ -69,6 +88,10 @@ const NEUTRAL_SIDEBAR = [242, 243, 245]; function tint(rgb, amount) { return rgb.map((c) => Math.round(c * amount + 255 * (1 - amount))); } +// Mix a colour towards black. +function shade(rgb, amount) { + return rgb.map((c) => Math.round(c * amount)); +} function findOption(list, key, fallbackKey) { return list.find((o) => o.key === key) || list.find((o) => o.key === fallbackKey); @@ -82,10 +105,16 @@ function one(v) { // Fold the stored overrides onto the defaults, dropping unknown keys/values so a // stale row, a hand-crafted query string or a checkbox pair can never produce an -// invalid theme. +// invalid theme. Fields not explicitly set fall back to the *layout's* default, +// which is why switching to "social" turns pink without touching anything else. function settings(overrides = {}) { - const out = { ...DEFAULTS }; + const layoutRaw = one(overrides.layout); + const layout = LAYOUTS.some((l) => l.key === layoutRaw) ? String(layoutRaw) : DEFAULTS.layout; + + const out = { ...DEFAULTS, ...(LAYOUT_DEFAULTS[layout] || {}), layout }; + for (const f of FELDER) { + if (f.key === 'layout') continue; const v = one(overrides[f.key]); if (v && f.optionen.some((o) => o.key === String(v))) out[f.key] = String(v); } @@ -100,32 +129,90 @@ function settings(overrides = {}) { function resolve(overrides = {}) { const s = settings(overrides); const accent = findOption(AKZENTE, s.akzent, 'anthrazit').rgb; + const social = s.layout === 'social'; let sidebarBg; if (s.sidebar === 'weiss') sidebarBg = [255, 255, 255]; else if (s.sidebar === 'getoent') sidebarBg = tint(accent, 0.08); else sidebarBg = NEUTRAL_SIDEBAR; - return { - layout: s.layout, - // Résumé palette — neutral base, configurable accent. - rc: { + // The playful layout derives its whole palette from the accent: a tinted page, + // near-white cards with a soft border, pill chips, and a warm dark ink that is + // the accent shaded down rather than plain black. + const rc = social + ? { + ink: shade(accent, 0.35), + sub: tint(shade(accent, 0.55), 0.75), + hair: tint(accent, 0.30), + accent, + pageBg: tint(accent, 0.10), + cardBg: [255, 255, 255], + cardBorder: tint(accent, 0.28), + // Sparkles/blob sit on the tinted page, so they need more punch than the + // card border or they simply vanish. + deko: tint(accent, 0.45), + pillBg: tint(accent, 0.18), + pillInk: shade(accent, 0.6), + sidebarBg, // unused in this layout, kept so the shape stays stable + track: tint(accent, 0.45), + onSide: shade(accent, 0.35), + onSideSub: tint(shade(accent, 0.55), 0.75), + } + : { ink: [33, 33, 33], sub: [92, 96, 100], hair: [208, 210, 214], accent, + pageBg: null, // no page tint in the classic layout + cardBg: [255, 255, 255], + cardBorder: [208, 210, 214], + deko: [208, 210, 214], // the classic layout draws no sparkles; kept for shape parity + pillBg: tint(accent, 0.12), + pillInk: accent, sidebarBg, track: [199, 202, 208], onSide: [38, 40, 44], onSideSub: [96, 100, 106], - }, - // Cover-letter palette — same accent, so both documents read as one set. - lc: { + }; + + // The cover letter always mirrors the résumé, so the two documents read as one + // set — a playful CV never arrives with a stiff monochrome letter. + const lc = social + ? { + ink: rc.ink, + muted: rc.sub, + hair: rc.hair, + accent, + pageBg: rc.pageBg, + cardBg: rc.cardBg, + cardBorder: rc.cardBorder, + deko: rc.deko, + pillBg: rc.pillBg, + pillInk: rc.pillInk, + } + : { ink: [26, 26, 26], muted: [92, 96, 100], hair: [206, 208, 212], accent, - }, + pageBg: null, + cardBg: [255, 255, 255], + cardBorder: [206, 208, 212], + deko: [206, 208, 212], + pillBg: tint(accent, 0.12), + pillInk: accent, + }; + + return { + layout: s.layout, + // Which typefaces the renderer registers and draws with. The playful layout + // uses a geometric sans plus a script face for the "Hallo, ich bin" line; + // the classic one stays on Lato and embeds nothing extra. + fonts: social + ? { sans: 'Poppins', script: 'Pacifico' } + : { sans: 'Lato', script: null }, + rc, + lc, foto: { anzeigen: s.foto_anzeigen !== '0', rund: s.foto_form === 'rund', @@ -136,26 +223,28 @@ function resolve(overrides = {}) { }; } -// Field list for the Vorlagen UI, with the current selection marked. +// Field list for the Vorlagen UI, with the current selection marked. Fields that +// only apply to one layout carry `nurLayout` so the form can hide them. function list(overrides = {}) { const s = settings(overrides); return FELDER.map((f) => ({ key: f.key, label: f.label, aktuell: s[f.key], + nurLayout: f.nurLayout || null, optionen: f.optionen.map((o) => ({ key: o.key, label: o.label, gewaehlt: o.key === s[f.key] })), })); } -// True when the user deviates from the shipped defaults. +// True when the user deviates from what the chosen layout would ship with. function isAngepasst(overrides = {}) { const s = settings(overrides); - return Object.keys(DEFAULTS).some((k) => s[k] !== DEFAULTS[k]); + const basis = { ...DEFAULTS, ...(LAYOUT_DEFAULTS[s.layout] || {}), layout: s.layout }; + return s.layout !== DEFAULTS.layout || Object.keys(basis).some((k) => s[k] !== basis[k]); } module.exports = { - DEFAULTS, AKZENTE, FELDER, + DEFAULTS, LAYOUT_DEFAULTS, AKZENTE, LAYOUTS, FELDER, settings, resolve, list, isAngepasst, - // The default theme, for callers that pass no overrides at all. defaultTheme: () => resolve({}), }; diff --git a/lib/documents.js b/lib/documents.js index dc2892d..b6a5018 100644 --- a/lib/documents.js +++ b/lib/documents.js @@ -12,19 +12,38 @@ const path = require('path'); const promptStore = require('./prompts'); const designStore = require('./design'); -// Embedded professional typeface for both cover letter and résumé: Lato -// (SIL Open Font License, see lib/fonts/OFL.txt). The base64 TTFs are read -// once at module load and registered per jsPDF instance. -const LATO_REGULAR_B64 = fs.readFileSync(path.join(__dirname, 'fonts', 'Lato-Regular.ttf')).toString('base64'); -const LATO_BOLD_B64 = fs.readFileSync(path.join(__dirname, 'fonts', 'Lato-Bold.ttf')).toString('base64'); +// Embedded typefaces (all SIL Open Font License — see lib/fonts/OFL*.txt): +// Lato — the classic layout's workhorse +// Poppins — geometric sans for the playful "social" layout +// Pacifico — script face for that layout's handwritten line +// Read once at module load; only the faces a layout actually uses are registered +// on its document, so a classic CV never carries the playful fonts' weight. +const FONT_FILES = { + 'Lato-Regular.ttf': ['Lato', 'normal'], + 'Lato-Bold.ttf': ['Lato', 'bold'], + 'Poppins-Regular.ttf': ['Poppins', 'normal'], + 'Poppins-Bold.ttf': ['Poppins', 'bold'], + 'Pacifico-Regular.ttf': ['Pacifico', 'normal'], +}; +const FONT_B64 = Object.fromEntries( + Object.keys(FONT_FILES).map((f) => [f, fs.readFileSync(path.join(__dirname, 'fonts', f)).toString('base64')]) +); -function makeDoc() { +// `t` = the resolved theme (lib/design.js). Its `fonts` decide what gets +// embedded. The chosen families are stashed on the document itself (not in a +// module variable) so two renders can never race each other's typeface. +function makeDoc(t) { + const sans = (t && t.fonts && t.fonts.sans) || 'Lato'; + const script = (t && t.fonts && t.fonts.script) || null; const doc = new jsPDF({ unit: 'mm', format: 'a4' }); - doc.addFileToVFS('Lato-Regular.ttf', LATO_REGULAR_B64); - doc.addFileToVFS('Lato-Bold.ttf', LATO_BOLD_B64); - doc.addFont('Lato-Regular.ttf', 'Lato', 'normal'); - doc.addFont('Lato-Bold.ttf', 'Lato', 'bold'); - doc.setFont('Lato'); + for (const [file, [family, style]] of Object.entries(FONT_FILES)) { + if (family !== sans && family !== script) continue; + doc.addFileToVFS(file, FONT_B64[file]); + doc.addFont(file, family, style); + } + doc.__sans = sans; + doc.__script = script || sans; + doc.setFont(sans); return doc; } @@ -599,7 +618,7 @@ function write(doc, S, dry, cur, text, o) { const pt = o.pt; const factor = o.factor == null ? 1.3 : o.factor; if (text == null || text === '') return; - doc.setFont('Lato', o.style || 'normal'); + doc.setFont(doc.__sans, o.style || 'normal'); doc.setFontSize(pt * S); if (o.charSpace) doc.setCharSpace(o.charSpace * S); const content = o.upper ? String(text).toUpperCase() : String(text); @@ -704,7 +723,7 @@ function sbHeading(doc, t, S, dry, cur, title) { const pt = 9; const cs = 1.0 * S; cur.y += 5 * S; - doc.setFont('Lato', 'bold'); + doc.setFont(doc.__sans, 'bold'); doc.setFontSize(pt * S); const label = String(title).toUpperCase(); if (!dry) { @@ -720,7 +739,7 @@ function sbBullets(doc, t, S, dry, cur, items, pt = 8.7) { const tx = SB_X + 3.4; const tw = SB_CW - 3.4; for (const it of items) { - doc.setFont('Lato', 'normal'); + doc.setFont(doc.__sans, 'normal'); doc.setFontSize(pt * S); const lines = doc.splitTextToSize(String(it), tw); lines.forEach((ln, i) => { @@ -742,7 +761,7 @@ function sbBullets(doc, t, S, dry, cur, items, pt = 8.7) { // Language row: name (left) + level (right-aligned in the sidebar column). function sbLangRow(doc, t, S, dry, cur, s) { const pt = 8.7; - doc.setFont('Lato', 'normal'); + doc.setFont(doc.__sans, 'normal'); doc.setFontSize(pt * S); if (!dry) { doc.setTextColor(t.rc.onSide[0], t.rc.onSide[1], t.rc.onSide[2]); @@ -818,7 +837,7 @@ function mHeading(doc, t, S, dry, cur, title) { const pt = 11; const cs = 0.9 * S; cur.y += 5.5 * S; - doc.setFont('Lato', 'bold'); + doc.setFont(doc.__sans, 'bold'); doc.setFontSize(pt * S); const label = String(title).toUpperCase(); const lineY = cur.y + pt * S * PT * 1.1; @@ -842,7 +861,7 @@ function cvBullets(doc, t, S, dry, cur, items, x, w, pt = 9.2) { const tx = x + 3.8; const tw = w - 3.8; for (const it of items) { - doc.setFont('Lato', 'normal'); + doc.setFont(doc.__sans, 'normal'); doc.setFontSize(pt * S); const lines = doc.splitTextToSize(String(it), tw); lines.forEach((ln, i) => { @@ -867,7 +886,7 @@ function cvExperience(doc, t, S, dry, cur, e) { const cx = MAIN_X + 1.4; // node / track centre x const titlePt = 11, datePt = 9.3; const dateW = 30; - doc.setFont('Lato', 'bold'); + doc.setFont(doc.__sans, 'bold'); doc.setFontSize(titlePt * S); const titleLines = doc.splitTextToSize(String(e.titel || ''), BODY_W - dateW); const titleLineH = titlePt * S * PT * 1.16; @@ -877,7 +896,7 @@ function cvExperience(doc, t, S, dry, cur, e) { doc.setTextColor(t.rc.ink[0], t.rc.ink[1], t.rc.ink[2]); titleLines.forEach((ln, i) => doc.text(ln, BODY_X, cur.y + i * titleLineH + titlePt * S * PT * 0.76)); if (e.zeitraum) { - doc.setFont('Lato', 'normal'); + doc.setFont(doc.__sans, 'normal'); doc.setFontSize(datePt * S); doc.setTextColor(t.rc.sub[0], t.rc.sub[1], t.rc.sub[2]); doc.text(String(e.zeitraum), MAIN_R, cur.y + datePt * S * PT * 0.76, { align: 'right' }); @@ -906,7 +925,7 @@ function composeTimeline(doc, t, S, dry, cur, entries) { function cvEducation(doc, t, S, dry, cur, e) { const pt = 10.5, datePt = 9.3, dateW = 30; - doc.setFont('Lato', 'bold'); + doc.setFont(doc.__sans, 'bold'); doc.setFontSize(pt * S); const lines = doc.splitTextToSize(String(e.abschluss || ''), BODY_W - dateW); const lineH = pt * S * PT * 1.16; @@ -914,7 +933,7 @@ function cvEducation(doc, t, S, dry, cur, e) { doc.setTextColor(t.rc.ink[0], t.rc.ink[1], t.rc.ink[2]); lines.forEach((ln, i) => doc.text(ln, BODY_X, cur.y + i * lineH + pt * S * PT * 0.76)); if (e.zeitraum) { - doc.setFont('Lato', 'normal'); + doc.setFont(doc.__sans, 'normal'); doc.setFontSize(datePt * S); doc.setTextColor(t.rc.sub[0], t.rc.sub[1], t.rc.sub[2]); doc.text(String(e.zeitraum), MAIN_R, cur.y + datePt * S * PT * 0.76, { align: 'right' }); @@ -971,17 +990,374 @@ function composeCV(doc, t, S, dry, ctx) { // onto a second page. So "110 %" enlarges a sparse CV but can never break the // one-page guarantee on a full one. function renderCV(cv, header, titel, foto, t) { - const doc = makeDoc(); + const doc = makeDoc(t); const ctx = { cv, header, titel, foto }; + const social = t.layout === 'social'; + const compose = social ? composeCVSocial : composeCV; + const top = social ? SO.top : RV.top; + const bottom = social ? SO.bottom : RV.bottom; + const S0 = t.scale; - const need = composeCV(doc, t, S0, true, ctx) - RV.top; - const avail = PAGE.h - RV.top - RV.bottom; + const need = compose(doc, t, S0, true, ctx) - top; + const avail = PAGE.h - top - bottom; let S = S0; if (need > avail) S = Math.max(MIN_SCALE, S0 * (avail / need) * 0.99); - composeCV(doc, t, S, false, ctx); + compose(doc, t, S, false, ctx); return Buffer.from(doc.output('arraybuffer')); } +// =========================================================================== +// "Social media" layout — the playful alternative +// +// A media-kit look: a tinted page, a round portrait, a script greeting over a +// huge name, hearts as section markers, rounded white cards for every entry and +// pill chips for skills and languages. Loud on purpose — meant for creative and +// social-media roles, not for a bank. +// +// It reuses the same machinery as the classic layout (dry-run measuring, the +// one-page auto-scale, the shared `write` helper), so it inherits the same +// guarantees. Only the drawing is different. +// =========================================================================== + +const SO = { + mx: 15, // page margin + get r() { return PAGE.w - this.mx; }, + get w() { return PAGE.w - 2 * this.mx; }, + top: 16, + bottom: 14, + pad: 4.6, // inner padding of a card + radius: 3.2, // corner radius of cards +}; + +// --- Decorative primitives ------------------------------------------------- + +// A heart, drawn as two Bézier lobes. `s` is the width in mm; the shape is +// centred on (cx, cy). Used as the section marker and the bullet glyph, because +// the sans faces don't carry a heart glyph we could rely on. +function heart(doc, cx, cy, s, color) { + const w = s, h = s; + doc.setFillColor(color[0], color[1], color[2]); + // Two cubic lobes, drawn from the bottom tip. In jsPDF's `lines`, a segment's + // control points and end point are all relative to that segment's *start*. + // Left lobe: tip -> up the left side -> down into the top notch. + // Right lobe: notch -> up the right side -> back to the tip. + doc.lines( + [ + [-w * 0.5, -h * 0.35, -w * 0.5, -h * 1.0, 0, -h * 0.65], + [w * 0.5, -h * 0.35, w * 0.5, h * 0.30, 0, h * 0.65], + ], + cx, cy + h * 0.36, // bottom tip (shape sits centred on cy) + [1, 1], 'F', true + ); +} + +// A four-pointed sparkle, the other motif from the reference: a thin star made +// of four concave points. +function sparkle(doc, cx, cy, s, color) { + const a = s / 2; + doc.setFillColor(color[0], color[1], color[2]); + // Four points, each a cubic whose control points both sit at the centre — that + // pulls the edges inwards and gives the concave, twinkling star shape. + doc.lines( + [ + [0, a, 0, a, a, a], // top -> right + [-a, 0, -a, 0, -a, a], // right -> bottom + [0, -a, 0, -a, -a, -a], // bottom -> left + [a, 0, a, 0, a, -a], // left -> top + ], + cx, cy - a, [1, 1], 'F', true + ); +} + +// A rounded card. Draws the frame only — the caller fills it. +function card(doc, t, S, x, y, w, h) { + doc.setFillColor(t.rc.cardBg[0], t.rc.cardBg[1], t.rc.cardBg[2]); + doc.setDrawColor(t.rc.cardBorder[0], t.rc.cardBorder[1], t.rc.cardBorder[2]); + doc.setLineWidth(0.4 * S); + doc.roundedRect(x, y, w, h, SO.radius * S, SO.radius * S, 'FD'); +} + +// A pill chip ("FOLLOWER" in the reference; here: periods, skills, languages). +// Returns its width so chips can be flowed into rows. +function pill(doc, t, S, dry, x, y, text, opts = {}) { + const pt = opts.pt || 8.2; + const padX = 2.6 * S; + const h = pt * S * PT * 1.85; + doc.setFont(doc.__sans, opts.style || 'bold'); + doc.setFontSize(pt * S); + const w = doc.getTextWidth(String(text)) + padX * 2; + if (!dry) { + const bg = opts.bg || t.rc.pillBg; + const ink = opts.ink || t.rc.pillInk; + doc.setFillColor(bg[0], bg[1], bg[2]); + doc.roundedRect(x, y, w, h, h / 2, h / 2, 'F'); + doc.setTextColor(ink[0], ink[1], ink[2]); + doc.text(String(text), x + padX, y + h / 2 + pt * S * PT * 0.34); + } + return { w, h }; +} + +// Flow pill chips across the available width, wrapping into new rows. +function pillRow(doc, t, S, dry, cur, items, x, maxW, opts = {}) { + let cx = x; + const gap = 2.0 * S; + let rowH = 0; + for (const it of items) { + const probe = pill(doc, t, S, true, 0, 0, it, opts); + if (cx > x && cx + probe.w > x + maxW) { // wrap + cx = x; + cur.y += rowH + gap; + rowH = 0; + } + const p = pill(doc, t, S, dry, cx, cur.y, it, opts); + cx += p.w + gap; + rowH = Math.max(rowH, p.h); + } + cur.y += rowH; +} + +// Section heading: centred, tracked, flanked by hairlines and a heart on each +// side — the reference's "♥ MEINE SOCIAL MEDIA KANÄLE ♥". +function soHeading(doc, t, S, dry, cur, title) { + const pt = 10.5; + cur.y += 6 * S; + const label = String(title).toUpperCase(); + doc.setFont(doc.__sans, 'bold'); + doc.setFontSize(pt * S); + const cs = 0.7 * S; + const midY = cur.y + pt * S * PT * 0.5; + if (!dry) { + doc.setCharSpace(cs); + const tw = doc.getTextWidth(label) + cs * label.length; + const cx = PAGE.w / 2; + doc.setTextColor(t.rc.accent[0], t.rc.accent[1], t.rc.accent[2]); + doc.text(label, cx, cur.y + pt * S * PT * 0.76, { align: 'center' }); + doc.setCharSpace(0); + + const gap = 5.5 * S; + const hx = tw / 2 + gap; + heart(doc, cx - hx, midY, 3.1 * S, t.rc.accent); + heart(doc, cx + hx, midY, 3.1 * S, t.rc.accent); + + doc.setDrawColor(t.rc.hair[0], t.rc.hair[1], t.rc.hair[2]); + doc.setLineWidth(0.4 * S); + const lineGap = hx + 3.6 * S; + doc.line(SO.mx + 4 * S, midY, cx - lineGap, midY); + doc.line(cx + lineGap, midY, SO.r - 4 * S, midY); + } + cur.y += pt * S * PT + 5 * S; +} + +// Bullet list with heart glyphs instead of squares. +function soBullets(doc, t, S, dry, cur, items, x, w, pt = 9) { + const tx = x + 4.4 * S; + const tw = w - 4.4 * S; + for (const it of items) { + doc.setFont(doc.__sans, 'normal'); + doc.setFontSize(pt * S); + const lines = doc.splitTextToSize(String(it), tw); + lines.forEach((ln, i) => { + if (!dry) { + if (i === 0) heart(doc, x + 1.5 * S, cur.y + pt * S * PT * 0.45, 2.3 * S, t.rc.accent); + doc.setTextColor(t.rc.ink[0], t.rc.ink[1], t.rc.ink[2]); + doc.text(ln, tx, cur.y + pt * S * PT * 0.76); + } + cur.y += pt * S * PT * 1.34; + }); + cur.y += 1.1 * S; + } +} + +// Measure a block by running its draw function dry, then draw the card behind it +// and the content on top. This is how every entry card gets an exact height +// without hard-coding one. +function soCard(doc, t, S, dry, cur, inner) { + const startY = cur.y; + const probe = { y: startY + SO.pad * S }; + inner(probe, true); + const h = (probe.y - startY) + SO.pad * S; + if (!dry) { + card(doc, t, S, SO.mx, startY, SO.w, h); + inner({ y: startY + SO.pad * S }, false); + } + cur.y = startY + h + 3.2 * S; +} + +// --- Résumé sections ------------------------------------------------------- + +// The hero: round portrait on the left, script greeting + huge name on the right, +// with the target role as a tracked, dot-separated line underneath. +function soHero(doc, t, S, dry, cur, { header, titel, foto }) { + const startY = cur.y; + const hasPhoto = Boolean(foto && foto.dataUrl); + const D = 44 * S; // portrait diameter + const px = SO.mx + 2 * S; + const textX = hasPhoto ? px + D + 9 * S : SO.mx + 2 * S; + const textW = SO.r - textX; + + if (hasPhoto && !dry) { + const p = fitPhoto(doc, foto, true); + if (p.ok) { + const r = D / 2; + const cx = px + r; + const cy = startY + r; + // Soft blob behind the portrait, like the reference's organic shape. + doc.setFillColor(t.rc.cardBorder[0], t.rc.cardBorder[1], t.rc.cardBorder[2]); + doc.circle(cx + 1.6 * S, cy + 1.2 * S, r + 1.8 * S, 'F'); + const dw = D * (p.dw / p.w); + const dh = D * (p.dh / p.h); + doc.saveGraphicsState(); + doc.circle(cx, cy, r, null); + doc.clip(); + doc.discardPath(); + doc.addImage(foto.dataUrl, foto.format || 'PNG', cx - dw / 2, cy - dh / 2, dw, dh); + doc.restoreGraphicsState(); + doc.setDrawColor(255, 255, 255); + doc.setLineWidth(1.2 * S); + doc.circle(cx, cy, r, 'S'); + } + } + + const cur2 = { y: startY + 2 * S }; + + // Script line — the one handwritten touch. + if (!dry) { + doc.setFont(doc.__script, 'normal'); + doc.setFontSize(14 * S); + doc.setTextColor(t.rc.accent[0], t.rc.accent[1], t.rc.accent[2]); + doc.text('Hallo, ich bin', textX, cur2.y + 14 * S * PT * 0.76); + } + cur2.y += 14 * S * PT * 1.25; + + // The name, as big as it fits. + const name = String(header.name || '').toUpperCase(); + let namePt = 30; + doc.setFont(doc.__sans, 'bold'); + for (; namePt > 15; namePt -= 0.5) { + doc.setFontSize(namePt * S); + if (doc.getTextWidth(name) <= textW) break; + } + write(doc, S, dry, cur2, name, { pt: namePt, style: 'bold', color: t.rc.accent, x: textX, width: textW, factor: 1.02 }); + + // Role line: "SYSTEMADMINISTRATOR · LINUX · NETZWERKE" + if (titel) { + cur2.y += 1.5 * S; + write(doc, S, dry, cur2, titel, { + pt: 8.8, style: 'bold', color: t.rc.sub, x: textX, width: textW, + factor: 1.35, charSpace: 0.55, upper: true, + }); + } + + if (!dry) { + sparkle(doc, SO.r - 6 * S, startY + 3 * S, 5.2 * S, t.rc.deko); + sparkle(doc, SO.r - 13 * S, startY + 9 * S, 3.0 * S, t.rc.deko); + } + + const photoBottom = hasPhoto ? startY + D + 2 * S : startY; + cur.y = Math.max(cur2.y, photoBottom) + 2 * S; +} + +function soExperience(doc, t, S, dry, cur, e) { + soCard(doc, t, S, dry, cur, (c, isDry) => { + const x = SO.mx + SO.pad * S; + const w = SO.w - 2 * SO.pad * S; + // Period as a pill, right-aligned on the first line. + let pillW = 0; + if (e.zeitraum) { + const probe = pill(doc, t, S, true, 0, 0, e.zeitraum); + pillW = probe.w; + pill(doc, t, S, isDry, SO.mx + SO.w - SO.pad * S - probe.w, c.y - 0.6 * S, e.zeitraum); + } + write(doc, S, isDry, c, e.titel || '', { + pt: 11.4, style: 'bold', color: t.rc.ink, x, width: w - pillW - 3 * S, factor: 1.2, + }); + if (e.firma) write(doc, S, isDry, c, e.firma, { pt: 9.4, style: 'bold', color: t.rc.accent, x, width: w, factor: 1.3 }); + if (e.punkte && e.punkte.length) { + c.y += 1.4 * S; + soBullets(doc, t, S, isDry, c, e.punkte, x, w); + } + }); +} + +function soEducation(doc, t, S, dry, cur, e) { + soCard(doc, t, S, dry, cur, (c, isDry) => { + const x = SO.mx + SO.pad * S; + const w = SO.w - 2 * SO.pad * S; + let pillW = 0; + if (e.zeitraum) { + const probe = pill(doc, t, S, true, 0, 0, e.zeitraum); + pillW = probe.w; + pill(doc, t, S, isDry, SO.mx + SO.w - SO.pad * S - probe.w, c.y - 0.6 * S, e.zeitraum); + } + write(doc, S, isDry, c, e.abschluss || '', { + pt: 10.6, style: 'bold', color: t.rc.ink, x, width: w - pillW - 3 * S, factor: 1.2, + }); + if (e.institution) write(doc, S, isDry, c, e.institution, { pt: 9.2, style: 'bold', color: t.rc.accent, x, width: w, factor: 1.28 }); + if (e.zusatz) write(doc, S, isDry, c, e.zusatz, { pt: 8.8, color: t.rc.sub, x, width: w, factor: 1.28 }); + }); +} + +function composeCVSocial(doc, t, S, dry, ctx) { + const { cv, header, titel, foto } = ctx; + if (!dry && t.rc.pageBg) { + doc.setFillColor(t.rc.pageBg[0], t.rc.pageBg[1], t.rc.pageBg[2]); + doc.rect(0, 0, PAGE.w, PAGE.h, 'F'); + } + const cur = { y: SO.top }; + + soHero(doc, t, S, dry, cur, { header, titel, foto }); + + // Contact + profile in one card — the "Willkommen in meiner bunten Welt" block. + const kontakt = buildContactLines(header); + if (cv.profil || kontakt.length) { + soCard(doc, t, S, dry, cur, (c, isDry) => { + const x = SO.mx + SO.pad * S; + const w = SO.w - 2 * SO.pad * S; + if (cv.profil) write(doc, S, isDry, c, cv.profil, { pt: 9.6, color: t.rc.ink, x, width: w, factor: 1.5 }); + if (kontakt.length) { + c.y += 2 * S; + pillRow(doc, t, S, isDry, c, kontakt, x, w, { style: 'normal', pt: 8.4 }); + } + }); + } + + if (cv.berufserfahrung.length) { + soHeading(doc, t, S, dry, cur, 'Berufserfahrung'); + cv.berufserfahrung.forEach((e) => soExperience(doc, t, S, dry, cur, e)); + } + + [['Studium', cv.studium], ['Berufsausbildung', cv.berufsausbildung], ['Weiterbildungen', cv.weiterbildungen]] + .forEach(([label, entries]) => { + if (!entries || !entries.length) return; + soHeading(doc, t, S, dry, cur, label); + entries.forEach((e) => soEducation(doc, t, S, dry, cur, e)); + }); + + if (cv.kenntnisse.length) { + soHeading(doc, t, S, dry, cur, 'Kernkompetenzen'); + const c = { y: cur.y }; + pillRow(doc, t, S, dry, c, cv.kenntnisse, SO.mx + 1 * S, SO.w - 2 * S); + cur.y = c.y + 2 * S; + } + + if (cv.sprachen.length) { + soHeading(doc, t, S, dry, cur, 'Sprachen'); + const c = { y: cur.y }; + const items = cv.sprachen.map((s) => (s.niveau ? `${s.sprache} — ${s.niveau}` : s.sprache)); + pillRow(doc, t, S, dry, c, items, SO.mx + 1 * S, SO.w - 2 * S, { style: 'normal' }); + cur.y = c.y + 2 * S; + } + + if (cv.hobbys.length) { + soHeading(doc, t, S, dry, cur, 'Interessen'); + const c = { y: cur.y }; + pillRow(doc, t, S, dry, c, cv.hobbys, SO.mx + 1 * S, SO.w - 2 * S, { style: 'normal' }); + cur.y = c.y + 2 * S; + } + + return cur.y; +} + // =========================================================================== // Cover letter — clean, single-column DIN-5008 business letter (monochrome) // =========================================================================== @@ -1105,7 +1481,7 @@ function composeLetter(doc, t, S, dry, cur, { letter, header, job, anlagen, sign doc.setDrawColor(t.lc.hair[0], t.lc.hair[1], t.lc.hair[2]); doc.setLineWidth(0.3 * S); doc.line(x, fy, right, fy); - doc.setFont('Lato', 'normal'); + doc.setFont(doc.__sans, 'normal'); doc.setFontSize(8 * S); doc.setTextColor(t.lc.muted[0], t.lc.muted[1], t.lc.muted[2]); doc.text(parts.join(' · '), (x + right) / 2, fy + 4 * S, { align: 'center' }); @@ -1113,15 +1489,178 @@ function composeLetter(doc, t, S, dry, cur, { letter, header, job, anlagen, sign } } +// =========================================================================== +// Cover letter, playful variant — mirrors the "social" résumé +// +// Same page tint, same accent, same cards, hearts and pills, same script line. +// The letter's *substance* is untouched: recipient block, date, subject, +// salutation, body, closing, signature and enclosures all stay where a German +// reader expects them. It's the same letter, dressed like the CV it arrives +// with — a playful résumé must never show up with a stiff monochrome letter. +// =========================================================================== + +function composeLetterSocial(doc, t, S, dry, cur, { letter, header, job, anlagen, signatur }) { + const x = SO.mx + 4 * S; + const right = SO.r - 4 * S; + const width = right - x; + const today = new Date().toLocaleDateString('de-DE', { day: '2-digit', month: '2-digit', year: 'numeric' }); + const betreff = letter.betreff || (job.stelle ? `Bewerbung als ${job.stelle}` : 'Bewerbung'); + const stadt = cityName(header); + + if (!dry && t.lc.pageBg) { + doc.setFillColor(t.lc.pageBg[0], t.lc.pageBg[1], t.lc.pageBg[2]); + doc.rect(0, 0, PAGE.w, PAGE.h, 'F'); + } + + // --- Letterhead: script greeting + big name, echoing the résumé's hero --- + if (!dry) { + doc.setFont(doc.__script, 'normal'); + doc.setFontSize(13 * S); + doc.setTextColor(t.lc.accent[0], t.lc.accent[1], t.lc.accent[2]); + doc.text('Hallo, ich bin', x, cur.y + 13 * S * PT * 0.76); + sparkle(doc, right - 4 * S, cur.y + 2 * S, 5 * S, t.lc.deko); + } + cur.y += 13 * S * PT * 1.25; + + const name = String(header.name || '').toUpperCase(); + let namePt = 24; + doc.setFont(doc.__sans, 'bold'); + for (; namePt > 13; namePt -= 0.5) { + doc.setFontSize(namePt * S); + if (doc.getTextWidth(name) <= width) break; + } + write(doc, S, dry, cur, name, { pt: namePt, style: 'bold', color: t.lc.accent, x, width, factor: 1.04 }); + if (header.headline) { + write(doc, S, dry, cur, header.headline, { + pt: 8.6, style: 'bold', color: t.lc.muted, x, width, factor: 1.35, charSpace: 0.55, upper: true, + }); + } + + // Heart-flanked divider instead of the classic hairline. + cur.y += 4 * S; + if (!dry) { + doc.setDrawColor(t.lc.hair[0], t.lc.hair[1], t.lc.hair[2]); + doc.setLineWidth(0.4 * S); + doc.line(x, cur.y, right - 7 * S, cur.y); + heart(doc, right - 3 * S, cur.y, 3.4 * S, t.lc.accent); + } + cur.y += 9 * S; + + // --- Recipient in a rounded card, with the date as a pill on the right --- + const emp = letter.empfaenger || {}; + const empFirma = emp.firma || job.firma || ''; + const empOrt = emp.ort || job.ort || ''; + const empLines = [ + empFirma, + emp.ansprechpartner ? `z. Hd. ${emp.ansprechpartner}` : '', + emp.adresse || '', + empOrt, + ].filter(Boolean); + + if (empLines.length) { + const startY = cur.y; + const inner = (c, isDry) => { + empLines.forEach((l) => write(doc, S, isDry, c, l, { + pt: 10, color: t.lc.ink, x: x + SO.pad * S, width: width - 2 * SO.pad * S - 34 * S, factor: 1.34, + })); + }; + const probe = { y: startY + SO.pad * S }; + inner(probe, true); + const h = (probe.y - startY) + SO.pad * S; + if (!dry) { + doc.setFillColor(t.lc.cardBg[0], t.lc.cardBg[1], t.lc.cardBg[2]); + doc.setDrawColor(t.lc.cardBorder[0], t.lc.cardBorder[1], t.lc.cardBorder[2]); + doc.setLineWidth(0.4 * S); + doc.roundedRect(x, startY, width, h, SO.radius * S, SO.radius * S, 'FD'); + inner({ y: startY + SO.pad * S }, false); + const d = stadt ? `${stadt}, ${today}` : today; + const p = pill(doc, t, S, true, 0, 0, d, { pt: 8.4 }); + pill(doc, t, S, false, x + width - SO.pad * S - p.w, startY + SO.pad * S, d, { pt: 8.4 }); + } + cur.y = startY + h + 7 * S; + } + + // --- Subject --- + write(doc, S, dry, cur, betreff, { pt: 12, style: 'bold', color: t.lc.accent, x, width, factor: 1.3 }); + + // --- Salutation + body --- + cur.y += 5 * S; + if (letter.anrede) { write(doc, S, dry, cur, letter.anrede, { pt: 10.2, color: t.lc.ink, x, width, factor: 1.4 }); cur.y += 2.6 * S; } + letter.absaetze.forEach((p, i) => { + if (i > 0) cur.y += 3 * S; + write(doc, S, dry, cur, p, { pt: 10.2, color: t.lc.ink, x, width, factor: 1.52 }); + }); + + // --- Closing + signature --- + cur.y += 5 * S; + if (letter.gruss) write(doc, S, dry, cur, letter.gruss, { pt: 10.2, color: t.lc.ink, x, width, factor: 1.3 }); + + let sigOk = false, sigW = 0, sigH = 0; + if (signatur && signatur.dataUrl) { + try { + const props = doc.getImageProperties(signatur.dataUrl); + const maxW = 52 * S, maxH = 22 * S; + sigW = maxW; + sigH = sigW * props.height / props.width; + if (sigH > maxH) { sigH = maxH; sigW = sigH * props.width / props.height; } + sigOk = props.width > 0 && props.height > 0; + } catch (e) { sigOk = false; } + } + if (sigOk) { + cur.y += 2.5 * S; + if (!dry) doc.addImage(signatur.dataUrl, signatur.format || 'PNG', x, cur.y, sigW, sigH); + cur.y += sigH; + } else { + cur.y += 12 * S; + write(doc, S, dry, cur, header.name, { pt: 10.2, style: 'bold', color: t.lc.accent, x, width, factor: 1.2 }); + } + + // --- Enclosures as pills --- + if (anlagen && anlagen.length) { + cur.y += 6 * S; + write(doc, S, dry, cur, 'Anlagen', { pt: 8.4, style: 'bold', color: t.lc.muted, x, width, factor: 1.4, charSpace: 0.5, upper: true }); + cur.y += 1 * S; + const c = { y: cur.y }; + pillRow(doc, t, S, dry, c, anlagen, x, width, { style: 'normal' }); + cur.y = c.y; + } + + // --- Footer contact strip, centred under a hairline --- + if (!dry) { + const parts = []; + if (header.adresse) { + const adr = String(header.adresse).split(/[\n,]/).map((s) => s.trim()).filter(Boolean).join(', '); + if (adr) parts.push(adr); + } + if (header.telefon) parts.push(header.telefon); + if (header.email) parts.push(header.email); + if (parts.length) { + const fy = PAGE.h - 15; + doc.setDrawColor(t.lc.hair[0], t.lc.hair[1], t.lc.hair[2]); + doc.setLineWidth(0.4 * S); + doc.line(x, fy, right, fy); + doc.setFont(doc.__sans, 'normal'); + doc.setFontSize(8 * S); + doc.setTextColor(t.lc.muted[0], t.lc.muted[1], t.lc.muted[2]); + doc.text(parts.join(' · '), (x + right) / 2, fy + 4.4 * S, { align: 'center' }); + heart(doc, x + 1 * S, fy, 2.6 * S, t.lc.accent); + heart(doc, right - 1 * S, fy, 2.6 * S, t.lc.accent); + } + } +} + function renderSingleColumn(compose, t) { - const doc = makeDoc(); + const doc = makeDoc(t); + const social = t.layout === 'social'; + const top = social ? SO.top : LET.top; + const bottom = social ? SO.bottom : LET.bottom; const S0 = t.scale; - const m = { y: LET.top }; compose(doc, t, S0, true, m); - const need = m.y - LET.top; - const avail = PAGE.h - LET.top - LET.bottom; + const m = { y: top }; compose(doc, t, S0, true, m); + const need = m.y - top; + const avail = PAGE.h - top - bottom; let S = S0; if (need > avail) S = Math.max(MIN_SCALE, S0 * (avail / need) * 0.99); - compose(doc, t, S, false, { y: LET.top }); + compose(doc, t, S, false, { y: top }); return Buffer.from(doc.output('arraybuffer')); } @@ -1141,10 +1680,13 @@ function renderLebenslaufPdf(cv, header, foto, design = null) { return renderCV(cv, header, header.headline || '', t.foto.anzeigen ? foto : null, t); } +// The letter always follows the résumé's layout — that is the contract of the +// design setting: one application, one look. function renderAnschreibenPdf(letter, header, job, anlagen, signatur, design = null) { const t = designStore.resolve(design || {}); + const compose = t.layout === 'social' ? composeLetterSocial : composeLetter; return renderSingleColumn( - (doc, th, S, dry, cur) => composeLetter(doc, th, S, dry, cur, { letter, header, job, anlagen, signatur }), + (doc, th, S, dry, cur) => compose(doc, th, S, dry, cur, { letter, header, job, anlagen, signatur }), t ); } diff --git a/lib/fonts/OFL-Pacifico.txt b/lib/fonts/OFL-Pacifico.txt new file mode 100644 index 0000000..33864cc --- /dev/null +++ b/lib/fonts/OFL-Pacifico.txt @@ -0,0 +1,93 @@ +Copyright 2018 The Pacifico Project Authors (https://github.com/googlefonts/Pacifico) + +This Font Software is licensed under the SIL Open Font License, Version 1.1. +This license is copied below, and is also available with a FAQ at: +http://scripts.sil.org/OFL + + +----------------------------------------------------------- +SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 +----------------------------------------------------------- + +PREAMBLE +The goals of the Open Font License (OFL) are to stimulate worldwide +development of collaborative font projects, to support the font creation +efforts of academic and linguistic communities, and to provide a free and +open framework in which fonts may be shared and improved in partnership +with others. + +The OFL allows the licensed fonts to be used, studied, modified and +redistributed freely as long as they are not sold by themselves. The +fonts, including any derivative works, can be bundled, embedded, +redistributed and/or sold with any software provided that any reserved +names are not used by derivative works. The fonts and derivatives, +however, cannot be released under any other type of license. The +requirement for fonts to remain under this license does not apply +to any document created using the fonts or their derivatives. + +DEFINITIONS +"Font Software" refers to the set of files released by the Copyright +Holder(s) under this license and clearly marked as such. This may +include source files, build scripts and documentation. + +"Reserved Font Name" refers to any names specified as such after the +copyright statement(s). + +"Original Version" refers to the collection of Font Software components as +distributed by the Copyright Holder(s). + +"Modified Version" refers to any derivative made by adding to, deleting, +or substituting -- in part or in whole -- any of the components of the +Original Version, by changing formats or by porting the Font Software to a +new environment. + +"Author" refers to any designer, engineer, programmer, technical +writer or other person who contributed to the Font Software. + +PERMISSION & CONDITIONS +Permission is hereby granted, free of charge, to any person obtaining +a copy of the Font Software, to use, study, copy, merge, embed, modify, +redistribute, and sell modified and unmodified copies of the Font +Software, subject to the following conditions: + +1) Neither the Font Software nor any of its individual components, +in Original or Modified Versions, may be sold by itself. + +2) Original or Modified Versions of the Font Software may be bundled, +redistributed and/or sold with any software, provided that each copy +contains the above copyright notice and this license. These can be +included either as stand-alone text files, human-readable headers or +in the appropriate machine-readable metadata fields within text or +binary files as long as those fields can be easily viewed by the user. + +3) No Modified Version of the Font Software may use the Reserved Font +Name(s) unless explicit written permission is granted by the corresponding +Copyright Holder. This restriction only applies to the primary font name as +presented to the users. + +4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font +Software shall not be used to promote, endorse or advertise any +Modified Version, except to acknowledge the contribution(s) of the +Copyright Holder(s) and the Author(s) or with their explicit written +permission. + +5) The Font Software, modified or unmodified, in part or in whole, +must be distributed entirely under this license, and must not be +distributed under any other license. The requirement for fonts to +remain under this license does not apply to any document created +using the Font Software. + +TERMINATION +This license becomes null and void if any of the above conditions are +not met. + +DISCLAIMER +THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT +OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE +COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL +DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM +OTHER DEALINGS IN THE FONT SOFTWARE. diff --git a/lib/fonts/OFL-Poppins.txt b/lib/fonts/OFL-Poppins.txt new file mode 100644 index 0000000..76df3b5 --- /dev/null +++ b/lib/fonts/OFL-Poppins.txt @@ -0,0 +1,93 @@ +Copyright 2020 The Poppins Project Authors (https://github.com/itfoundry/Poppins) + +This Font Software is licensed under the SIL Open Font License, Version 1.1. +This license is copied below, and is also available with a FAQ at: +http://scripts.sil.org/OFL + + +----------------------------------------------------------- +SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 +----------------------------------------------------------- + +PREAMBLE +The goals of the Open Font License (OFL) are to stimulate worldwide +development of collaborative font projects, to support the font creation +efforts of academic and linguistic communities, and to provide a free and +open framework in which fonts may be shared and improved in partnership +with others. + +The OFL allows the licensed fonts to be used, studied, modified and +redistributed freely as long as they are not sold by themselves. The +fonts, including any derivative works, can be bundled, embedded, +redistributed and/or sold with any software provided that any reserved +names are not used by derivative works. The fonts and derivatives, +however, cannot be released under any other type of license. The +requirement for fonts to remain under this license does not apply +to any document created using the fonts or their derivatives. + +DEFINITIONS +"Font Software" refers to the set of files released by the Copyright +Holder(s) under this license and clearly marked as such. This may +include source files, build scripts and documentation. + +"Reserved Font Name" refers to any names specified as such after the +copyright statement(s). + +"Original Version" refers to the collection of Font Software components as +distributed by the Copyright Holder(s). + +"Modified Version" refers to any derivative made by adding to, deleting, +or substituting -- in part or in whole -- any of the components of the +Original Version, by changing formats or by porting the Font Software to a +new environment. + +"Author" refers to any designer, engineer, programmer, technical +writer or other person who contributed to the Font Software. + +PERMISSION & CONDITIONS +Permission is hereby granted, free of charge, to any person obtaining +a copy of the Font Software, to use, study, copy, merge, embed, modify, +redistribute, and sell modified and unmodified copies of the Font +Software, subject to the following conditions: + +1) Neither the Font Software nor any of its individual components, +in Original or Modified Versions, may be sold by itself. + +2) Original or Modified Versions of the Font Software may be bundled, +redistributed and/or sold with any software, provided that each copy +contains the above copyright notice and this license. These can be +included either as stand-alone text files, human-readable headers or +in the appropriate machine-readable metadata fields within text or +binary files as long as those fields can be easily viewed by the user. + +3) No Modified Version of the Font Software may use the Reserved Font +Name(s) unless explicit written permission is granted by the corresponding +Copyright Holder. This restriction only applies to the primary font name as +presented to the users. + +4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font +Software shall not be used to promote, endorse or advertise any +Modified Version, except to acknowledge the contribution(s) of the +Copyright Holder(s) and the Author(s) or with their explicit written +permission. + +5) The Font Software, modified or unmodified, in part or in whole, +must be distributed entirely under this license, and must not be +distributed under any other license. The requirement for fonts to +remain under this license does not apply to any document created +using the Font Software. + +TERMINATION +This license becomes null and void if any of the above conditions are +not met. + +DISCLAIMER +THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT +OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE +COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL +DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM +OTHER DEALINGS IN THE FONT SOFTWARE. diff --git a/lib/fonts/Pacifico-Regular.ttf b/lib/fonts/Pacifico-Regular.ttf new file mode 100644 index 0000000..27765d0 Binary files /dev/null and b/lib/fonts/Pacifico-Regular.ttf differ diff --git a/lib/fonts/Poppins-Bold.ttf b/lib/fonts/Poppins-Bold.ttf new file mode 100644 index 0000000..1982f38 Binary files /dev/null and b/lib/fonts/Poppins-Bold.ttf differ diff --git a/lib/fonts/Poppins-Regular.ttf b/lib/fonts/Poppins-Regular.ttf new file mode 100644 index 0000000..0bda228 Binary files /dev/null and b/lib/fonts/Poppins-Regular.ttf differ diff --git a/server.js b/server.js index 9982747..e7a43c0 100644 --- a/server.js +++ b/server.js @@ -2001,8 +2001,18 @@ initializeDatabase().then(() => { app.post('/vorlagen/design', async (req, res) => { try { const gewaehlt = designStore.settings(req.body); + // Compare against what the *chosen layout* ships with, not the global + // defaults — otherwise picking "social" would persist its own pink/round + // defaults as if the user had overridden them. `layout` itself keeps the + // global default as its yardstick, so choosing a non-default layout is + // stored (comparing it against itself would never save anything). + const basis = { + ...designStore.DEFAULTS, + ...(designStore.LAYOUT_DEFAULTS[gewaehlt.layout] || {}), + layout: designStore.DEFAULTS.layout, + }; for (const [key, value] of Object.entries(gewaehlt)) { - if (value === designStore.DEFAULTS[key]) { + if (value === basis[key]) { await dbRun('DELETE FROM design WHERE key = ?', [key]); } else { await dbRun( diff --git a/views/vorlagen.ejs b/views/vorlagen.ejs index 280f39a..c0326d6 100644 --- a/views/vorlagen.ejs +++ b/views/vorlagen.ejs @@ -255,27 +255,30 @@
- Aussehen von Anschreiben und Lebenslauf. Das Grundlayout (Aufbau, Seitenränder, DIN-gerechter Brief) - bleibt fest – der Lebenslauf passt immer auf eine Seite. Die Vorschau zeigt Musterinhalte mit deinem - Namen, Foto und deiner Unterschrift, ohne dass die KI läuft. + Aussehen von Anschreiben und Lebenslauf. Das Anschreiben übernimmt immer das Layout des + Lebenslaufs, damit beide Dokumente als ein Set ankommen. Der Lebenslauf passt in jedem Layout + garantiert auf eine Seite. Die Vorschau zeigt Musterinhalte mit deinem Namen, Foto und deiner + Unterschrift, ohne dass die KI läuft.
+Klassisch mit Sidebar – zurückhaltend, monochrom, für klassische Bewerbungen (Standard).
+Social Media – verspielt: getönte Seite, runde Karten, Herzchen, Schreibschrift. + Bewusst auffällig – passend für kreative und Social-Media-Stellen, nicht für die Bank.
+