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 }); } }); }