Social-Design: Herz-Abstaende korrigiert + Icons fuer die Kontaktdaten

Die Abschnittsueberschriften wurden mit align:'center' zentriert. jsPDF misst
dafuer mit getTextWidth(), das den charSpace ignoriert, rendert den Text aber
gesperrt - er lief nach rechts, waehrend die Herzen symmetrisch sassen. Ergebnis:
links sichtbar mehr Luft als rechts. Die Ueberschrift wird jetzt selbst zentriert,
gemessen an der tatsaechlichen Tinte (Sperrung zaehlt nur zwischen den Glyphen,
nicht nach dem letzten), und die Herzen haengen an den echten Textkanten.

Kontaktdaten bekommen passende Icons: Umschlag, Telefon, Map-Pin, Globus,
Kalender, Ausweis. Als Vektoren gezeichnet, weil kein Icon-Glyph in der Schrift
vorausgesetzt werden kann; sie skalieren mit der Seitenskalierung mit.
buildContactLines liefert dafuer jetzt {typ, text} - die klassische Sidebar nutzt
weiterhin nur den Text und bleibt unveraendert schlicht.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-13 01:30:56 +02:00
co-authored by Claude Opus 4.8
parent 0e1d9b3322
commit 66366659d2
+119 -23
View File
@@ -705,15 +705,17 @@ function fitPhoto(doc, foto, rund = false) {
} catch (e) { return MISS; } } catch (e) { return MISS; }
} }
// Each entry carries a `typ` so a layout can pick a matching icon; the classic
// sidebar just prints `text` and ignores it.
function buildContactLines(header) { function buildContactLines(header) {
const lines = []; const lines = [];
if (header.email) lines.push(header.email); if (header.email) lines.push({ typ: 'email', text: header.email });
if (header.telefon) lines.push(header.telefon); if (header.telefon) lines.push({ typ: 'telefon', text: header.telefon });
const ort = cityName(header) || header.ort; const ort = cityName(header) || header.ort;
if (ort) lines.push(ort); if (ort) lines.push({ typ: 'ort', text: ort });
if (header.webseite) lines.push(header.webseite); if (header.webseite) lines.push({ typ: 'web', text: header.webseite });
if (header.geburtsdatum) lines.push(`Geb. ${header.geburtsdatum}`); if (header.geburtsdatum) lines.push({ typ: 'geburtstag', text: `Geb. ${header.geburtsdatum}` });
if (header.fuehrerschein) lines.push(`Führerschein ${header.fuehrerschein}`); if (header.fuehrerschein) lines.push({ typ: 'fuehrerschein', text: `Führerschein ${header.fuehrerschein}` });
return lines; return lines;
} }
@@ -809,7 +811,7 @@ function composeSidebar(doc, t, S, dry, { cv, header, foto }) {
const contact = buildContactLines(header); const contact = buildContactLines(header);
if (contact.length) { if (contact.length) {
sbHeading(doc, t, S, dry, cur, 'Kontakt'); sbHeading(doc, t, S, dry, cur, 'Kontakt');
contact.forEach((line) => write(doc, S, dry, cur, line, { pt: 8.6, color: t.rc.onSideSub, x: SB_X, width: SB_CW, factor: 1.5 })); contact.forEach((line) => write(doc, S, dry, cur, line.text, { pt: 8.6, color: t.rc.onSideSub, x: SB_X, width: SB_CW, factor: 1.5 }));
} }
if (cv.kenntnisse.length) { if (cv.kenntnisse.length) {
sbHeading(doc, t, S, dry, cur, 'Kernkompetenzen'); sbHeading(doc, t, S, dry, cur, 'Kernkompetenzen');
@@ -1069,6 +1071,78 @@ function sparkle(doc, cx, cy, s, color) {
); );
} }
// Contact icons, drawn as strokes so they scale with the page-fit and never
// depend on an icon font being present. Each one is centred on (cx, cy) and fits
// inside a square of side `s`.
function icon(doc, typ, cx, cy, s, color, S) {
const lw = Math.max(0.18, 0.16 * s);
doc.setDrawColor(color[0], color[1], color[2]);
doc.setFillColor(color[0], color[1], color[2]);
doc.setLineWidth(lw);
const h = s / 2;
if (typ === 'email') {
// Envelope: a rectangle with the flap drawn as two strokes to the centre.
const w = s, hh = s * 0.72;
const x = cx - w / 2, y = cy - hh / 2;
doc.roundedRect(x, y, w, hh, lw, lw, 'S');
doc.lines([[w / 2, hh * 0.52], [w / 2, -hh * 0.52]], x, y + lw * 0.4, [1, 1], 'S');
return;
}
if (typ === 'telefon') {
// Handset: a rounded body with the earpiece/mouthpiece implied by the fill.
const w = s * 0.66, hh = s * 0.96;
doc.roundedRect(cx - w / 2, cy - hh / 2, w, hh, w * 0.28, w * 0.28, 'S');
doc.circle(cx, cy + hh * 0.28, lw * 0.9, 'F');
doc.line(cx - w * 0.22, cy - hh * 0.3, cx + w * 0.22, cy - hh * 0.3);
return;
}
if (typ === 'ort') {
// Map pin: a circle with an open V tapering to the point. Drawn open (not a
// closed triangle) so no horizontal edge cuts across the circle.
const r = s * 0.32;
const top = cy - s * 0.16;
doc.circle(cx, top, r, 'S');
doc.lines(
[[r * 0.72, r * 1.55], [r * 0.72, -r * 1.55]],
cx - r * 0.72, top + r * 0.5, [1, 1], 'S', false
);
doc.circle(cx, top, r * 0.3, 'F');
return;
}
if (typ === 'web') {
// Globe: circle, equator, and one narrow ellipse as the meridian. Two
// mirrored arcs would cross into an "X" at this size.
const r = s * 0.46;
doc.circle(cx, cy, r, 'S');
doc.line(cx - r, cy, cx + r, cy);
doc.ellipse(cx, cy, r * 0.44, r, 'S');
return;
}
if (typ === 'geburtstag') {
// Calendar: a sheet with two rings and a header rule.
const w = s * 0.92, hh = s * 0.82;
const x = cx - w / 2, y = cy - hh / 2 + s * 0.08;
doc.roundedRect(x, y, w, hh, lw, lw, 'S');
doc.line(x, y + hh * 0.32, x + w, y + hh * 0.32);
doc.line(x + w * 0.28, y - s * 0.12, x + w * 0.28, y + hh * 0.08);
doc.line(x + w * 0.72, y - s * 0.12, x + w * 0.72, y + hh * 0.08);
return;
}
if (typ === 'fuehrerschein') {
// ID card: a landscape card with a portrait block and two text rules.
const w = s, hh = s * 0.68;
const x = cx - w / 2, y = cy - hh / 2;
doc.roundedRect(x, y, w, hh, lw, lw, 'S');
doc.circle(x + w * 0.28, y + hh * 0.42, hh * 0.17, 'S');
doc.line(x + w * 0.52, y + hh * 0.36, x + w * 0.84, y + hh * 0.36);
doc.line(x + w * 0.52, y + hh * 0.62, x + w * 0.84, y + hh * 0.62);
return;
}
// Fallback: a small dot, so an unknown type never draws garbage.
doc.circle(cx, cy, h * 0.3, 'F');
}
// A rounded card. Draws the frame only — the caller fills it. // A rounded card. Draws the frame only — the caller fills it.
function card(doc, t, S, x, y, w, h) { function card(doc, t, S, x, y, w, h) {
doc.setFillColor(t.rc.cardBg[0], t.rc.cardBg[1], t.rc.cardBg[2]); doc.setFillColor(t.rc.cardBg[0], t.rc.cardBg[1], t.rc.cardBg[2]);
@@ -1077,22 +1151,31 @@ function card(doc, t, S, x, y, w, h) {
doc.roundedRect(x, y, w, h, SO.radius * S, SO.radius * S, 'FD'); doc.roundedRect(x, y, w, h, SO.radius * S, SO.radius * S, 'FD');
} }
// A pill chip ("FOLLOWER" in the reference; here: periods, skills, languages). // A pill chip ("FOLLOWER" in the reference; here: periods, skills, languages,
// Returns its width so chips can be flowed into rows. // contact details). `item` is a plain string, or { text, icon } to prefix it
function pill(doc, t, S, dry, x, y, text, opts = {}) { // with one of the contact icons. Returns its size so chips can be flowed.
function pill(doc, t, S, dry, x, y, item, opts = {}) {
const text = String((item && item.text !== undefined) ? item.text : item);
const ikon = (item && item.icon) || opts.icon || null;
const pt = opts.pt || 8.2; const pt = opts.pt || 8.2;
const padX = 2.6 * S; const padX = 2.6 * S;
const h = pt * S * PT * 1.85; const h = pt * S * PT * 1.85;
const iconS = ikon ? pt * S * PT * 0.95 : 0;
const iconGap = ikon ? 1.5 * S : 0;
doc.setFont(doc.__sans, opts.style || 'bold'); doc.setFont(doc.__sans, opts.style || 'bold');
doc.setFontSize(pt * S); doc.setFontSize(pt * S);
const w = doc.getTextWidth(String(text)) + padX * 2; const w = doc.getTextWidth(text) + padX * 2 + iconS + iconGap;
if (!dry) { if (!dry) {
const bg = opts.bg || t.rc.pillBg; const bg = opts.bg || t.rc.pillBg;
const ink = opts.ink || t.rc.pillInk; const ink = opts.ink || t.rc.pillInk;
doc.setFillColor(bg[0], bg[1], bg[2]); doc.setFillColor(bg[0], bg[1], bg[2]);
doc.roundedRect(x, y, w, h, h / 2, h / 2, 'F'); doc.roundedRect(x, y, w, h, h / 2, h / 2, 'F');
if (ikon) icon(doc, ikon, x + padX + iconS / 2, y + h / 2, iconS, ink, S);
doc.setTextColor(ink[0], ink[1], ink[2]); doc.setTextColor(ink[0], ink[1], ink[2]);
doc.text(String(text), x + padX, y + h / 2 + pt * S * PT * 0.34); doc.text(text, x + padX + iconS + iconGap, y + h / 2 + pt * S * PT * 0.34);
} }
return { w, h }; return { w, h };
} }
@@ -1127,23 +1210,35 @@ function soHeading(doc, t, S, dry, cur, title) {
const cs = 0.7 * S; const cs = 0.7 * S;
const midY = cur.y + pt * S * PT * 0.5; const midY = cur.y + pt * S * PT * 0.5;
if (!dry) { if (!dry) {
doc.setCharSpace(cs); // Centre the label by hand. jsPDF's align:'center' measures with
const tw = doc.getTextWidth(label) + cs * label.length; // getTextWidth(), which ignores charSpace, but *renders* with it: the text
// drifted right, so the left heart sat visibly further from the word than
// the right one. Measure the ink ourselves — the letter-spacing counts only
// between glyphs (n-1 gaps); the trailing one PDF emits after the last glyph
// is not ink and must not shift the centre.
const inkW = doc.getTextWidth(label) + cs * Math.max(0, label.length - 1);
const cx = PAGE.w / 2; const cx = PAGE.w / 2;
const textX = cx - inkW / 2;
doc.setCharSpace(cs);
doc.setTextColor(t.rc.accent[0], t.rc.accent[1], t.rc.accent[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.text(label, textX, cur.y + pt * S * PT * 0.76);
doc.setCharSpace(0); doc.setCharSpace(0);
const gap = 5.5 * S; // Hearts anchored to the real ink edges, so both gaps are identical.
const hx = tw / 2 + gap; const gap = 5.2 * S;
heart(doc, cx - hx, midY, 3.1 * S, t.rc.accent); const heartS = 3.1 * S;
heart(doc, cx + hx, midY, 3.1 * S, t.rc.accent); const heartR = heartS / 2;
const leftHeartX = textX - gap - heartR;
const rightHeartX = textX + inkW + gap + heartR;
heart(doc, leftHeartX, midY, heartS, t.rc.accent);
heart(doc, rightHeartX, midY, heartS, t.rc.accent);
doc.setDrawColor(t.rc.hair[0], t.rc.hair[1], t.rc.hair[2]); doc.setDrawColor(t.rc.hair[0], t.rc.hair[1], t.rc.hair[2]);
doc.setLineWidth(0.4 * S); doc.setLineWidth(0.4 * S);
const lineGap = hx + 3.6 * S; const lineGap = 3.4 * S;
doc.line(SO.mx + 4 * S, midY, cx - lineGap, midY); doc.line(SO.mx + 4 * S, midY, leftHeartX - heartR - lineGap, midY);
doc.line(cx + lineGap, midY, SO.r - 4 * S, midY); doc.line(rightHeartX + heartR + lineGap, midY, SO.r - 4 * S, midY);
} }
cur.y += pt * S * PT + 5 * S; cur.y += pt * S * PT + 5 * S;
} }
@@ -1316,7 +1411,8 @@ function composeCVSocial(doc, t, S, dry, ctx) {
if (cv.profil) write(doc, S, isDry, c, cv.profil, { pt: 9.6, color: t.rc.ink, x, width: w, factor: 1.5 }); 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) { if (kontakt.length) {
c.y += 2 * S; c.y += 2 * S;
pillRow(doc, t, S, isDry, c, kontakt, x, w, { style: 'normal', pt: 8.4 }); const chips = kontakt.map((k) => ({ text: k.text, icon: k.typ }));
pillRow(doc, t, S, isDry, c, chips, x, w, { style: 'normal', pt: 8.4 });
} }
}); });
} }