Zweites Lebenslauf-Design "Social Media" + Anschreiben folgt dem Layout

Der Lebenslauf kann jetzt zwischen zwei Layouts waehlen, und das Anschreiben
uebernimmt immer das Layout des Lebenslaufs - beide Dokumente kommen als ein Set
beim Unternehmen an.

"Social Media" ist die verspielte Variante nach der Media-Kit-Vorlage: getoente
Seite, rundes Portrait mit Blob, Schreibschrift-Zeile ueber einem grossen Namen,
Herzchen als Abschnittsmarker und Bullets, abgerundete Karten je Station,
Pill-Chips fuer Kompetenzen und Sprachen, Glitzer-Sterne als Deko. Bewusst laut -
gedacht fuer kreative und Social-Media-Stellen.

Die gesamte Farbwelt (Seite, Karten, Pills, Deko) wird aus der Akzentfarbe
abgeleitet, das Layout funktioniert also auch in Violett oder Tiefblau. Neue
Akzente Pink und Violett; das Layout bringt eigene Defaults mit (pink, rundes
Foto), sodass ein Wechsel sofort stimmig aussieht.

Neue Schriften (SIL OFL): Poppins fuer die geometrische Sans, Pacifico fuer die
Schreibschrift. Es werden nur die Fonts eingebettet, die das gewaehlte Layout
nutzt - ein klassischer Lebenslauf traegt Poppins/Pacifico nicht mit.

Herz und Sparkle sind als Bezier-Pfade gezeichnet, weil sich auf ein Herz-Glyph
in der Schrift nicht verlassen werden kann. Das neue Layout nutzt dieselbe
Mess- und Skalier-Mechanik wie das klassische, erbt also die Ein-Seiten-Garantie.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-13 01:15:02 +02:00
co-authored by Claude Opus 4.8
parent 9f7349af4e
commit 0e1d9b3322
9 changed files with 917 additions and 75 deletions
+121 -32
View File
@@ -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({}),
};
+574 -32
View File
@@ -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
);
}
+93
View File
@@ -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.
+93
View File
@@ -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.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+11 -1
View File
@@ -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(
+25 -10
View File
@@ -255,27 +255,30 @@
<!-- Design of the generated PDFs -->
<h2 id="design" class="text-xl font-bold text-gray-800 dark:text-white mt-10 mb-2 scroll-mt-4">Design der Unterlagen</h2>
<p class="text-sm text-gray-500 dark:text-gray-400 mb-6">
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. <strong>Das Anschreiben übernimmt immer das Layout des
Lebenslaufs</strong>, 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.
</p>
<div class="rounded-lg bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 px-4 py-3 mb-6 text-sm text-blue-900 dark:text-blue-200">
<p><strong>Klassisch mit Sidebar</strong> zurückhaltend, monochrom, für klassische Bewerbungen (Standard).</p>
<p class="mt-1"><strong>Social Media</strong> verspielt: getönte Seite, runde Karten, Herzchen, Schreibschrift.
Bewusst auffällig passend für kreative und Social-Media-Stellen, nicht für die Bank.</p>
</div>
<section class="bg-white dark:bg-gray-800 rounded-lg shadow-md p-6 mb-8">
<form action="/vorlagen/design" method="POST" class="space-y-4" id="designForm">
<div class="grid gap-4 sm:grid-cols-2">
<% designFelder.forEach(f => { %>
<div>
<div data-feld="<%= f.key %>" <%- f.nurLayout ? 'data-nur-layout="' + f.nurLayout + '"' : '' %>>
<label class="block text-xs font-medium text-gray-500 dark:text-gray-400 mb-1"><%= f.label %></label>
<select name="<%= f.key %>" <%= f.optionen.length < 2 ? 'disabled' : '' %>
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md bg-white dark:bg-gray-700 text-gray-900 dark:text-white disabled:opacity-60">
<select name="<%= f.key %>"
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md bg-white dark:bg-gray-700 text-gray-900 dark:text-white">
<% f.optionen.forEach(o => { %>
<option value="<%= o.key %>" <%= o.gewaehlt ? 'selected' : '' %>><%= o.label %></option>
<% }) %>
</select>
<% if (f.optionen.length < 2) { %>
<input type="hidden" name="<%= f.key %>" value="<%= f.aktuell %>">
<p class="text-xs text-gray-400 dark:text-gray-500 mt-1">Weitere Layouts folgen.</p>
<% } %>
</div>
<% }) %>
</div>
@@ -417,6 +420,18 @@
const l = document.getElementById('vorschauLebenslauf');
if (a) a.addEventListener('click', () => oeffne('anschreiben'));
if (l) l.addEventListener('click', () => oeffne('lebenslauf'));
// Hide fields that only apply to one layout (e.g. the sidebar tint has
// no meaning in the social layout).
const layoutSel = form.querySelector('[name="layout"]');
function syncFelder() {
const aktiv = layoutSel ? layoutSel.value : 'sidebar';
form.querySelectorAll('[data-nur-layout]').forEach((el) => {
el.style.display = el.dataset.nurLayout === aktiv ? '' : 'none';
});
}
if (layoutSel) layoutSel.addEventListener('change', syncFelder);
syncFelder();
})();
</script>
</body>