From 66366659d24bfeca4da35e285cc2c09d2bd1c160 Mon Sep 17 00:00:00 2001 From: Thomas Hackner Date: Mon, 13 Jul 2026 01:30:56 +0200 Subject: [PATCH] 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 --- lib/documents.js | 142 +++++++++++++++++++++++++++++++++++++++-------- 1 file changed, 119 insertions(+), 23 deletions(-) diff --git a/lib/documents.js b/lib/documents.js index b6a5018..d42d95b 100644 --- a/lib/documents.js +++ b/lib/documents.js @@ -705,15 +705,17 @@ function fitPhoto(doc, foto, rund = false) { } 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) { const lines = []; - if (header.email) lines.push(header.email); - if (header.telefon) lines.push(header.telefon); + if (header.email) lines.push({ typ: 'email', text: header.email }); + if (header.telefon) lines.push({ typ: 'telefon', text: header.telefon }); const ort = cityName(header) || header.ort; - if (ort) lines.push(ort); - if (header.webseite) lines.push(header.webseite); - if (header.geburtsdatum) lines.push(`Geb. ${header.geburtsdatum}`); - if (header.fuehrerschein) lines.push(`Führerschein ${header.fuehrerschein}`); + if (ort) lines.push({ typ: 'ort', text: ort }); + if (header.webseite) lines.push({ typ: 'web', text: header.webseite }); + if (header.geburtsdatum) lines.push({ typ: 'geburtstag', text: `Geb. ${header.geburtsdatum}` }); + if (header.fuehrerschein) lines.push({ typ: 'fuehrerschein', text: `Führerschein ${header.fuehrerschein}` }); return lines; } @@ -809,7 +811,7 @@ function composeSidebar(doc, t, S, dry, { cv, header, foto }) { const contact = buildContactLines(header); if (contact.length) { 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) { 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. function card(doc, t, S, x, y, w, h) { 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'); } -// 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 = {}) { +// A pill chip ("FOLLOWER" in the reference; here: periods, skills, languages, +// contact details). `item` is a plain string, or { text, icon } to prefix it +// 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 padX = 2.6 * S; 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.setFontSize(pt * S); - const w = doc.getTextWidth(String(text)) + padX * 2; + const w = doc.getTextWidth(text) + padX * 2 + iconS + iconGap; + 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'); + if (ikon) icon(doc, ikon, x + padX + iconS / 2, y + h / 2, iconS, ink, S); 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 }; } @@ -1127,23 +1210,35 @@ function soHeading(doc, t, S, dry, cur, title) { 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; + // Centre the label by hand. jsPDF's align:'center' measures with + // 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 textX = cx - inkW / 2; + + doc.setCharSpace(cs); 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); - 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); + // Hearts anchored to the real ink edges, so both gaps are identical. + const gap = 5.2 * S; + const heartS = 3.1 * S; + 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.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); + const lineGap = 3.4 * S; + doc.line(SO.mx + 4 * S, midY, leftHeartX - heartR - lineGap, midY); + doc.line(rightHeartX + heartR + lineGap, midY, SO.r - 4 * S, midY); } 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 (kontakt.length) { 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 }); } }); }