/* ============================================================ resume_files.jsx — «скачать резюме». PDF собираем руками (без библиотек): A4, шрифт Helvetica, фотография вставляется как JPEG-объект. Кириллицы в базовых шрифтах PDF нет, поэтому PDF — всегда английская версия (её и ждёт иностранный работодатель). Word-файл делаем HTML-документом: Word открывает его как .doc и кириллица в нём работает, поэтому там доступны оба языка. ============================================================ */ /* ---------- мелкие помощники ---------- */ const enc = new TextEncoder(); function pdfEscape(s) { return String(s).replace(/\\/g, '\\\\').replace(/\(/g, '\\(').replace(/\)/g, '\\)'); } /* Латинизация: в PDF-шрифте нет кириллицы, поэтому чистим строку. */ function toLatin(s) { return String(s).replace(/[^\x20-\x7E]/g, (ch) => ({ '—': '-', '–': '-', '·': '-', '«': '"', '»': '"', '’': "'", '€': 'EUR', '₩': 'KRW', '¥': 'JPY', }[ch] || '')); } /* Размеры JPEG — читаем из маркера SOFn. */ function jpegSize(bytes) { let i = 2; while (i < bytes.length) { if (bytes[i] !== 0xFF) { i++; continue; } const m = bytes[i + 1]; if (m >= 0xC0 && m <= 0xCF && m !== 0xC4 && m !== 0xC8 && m !== 0xCC) { return { h: (bytes[i + 5] << 8) | bytes[i + 6], w: (bytes[i + 7] << 8) | bytes[i + 8] }; } i += 2 + ((bytes[i + 2] << 8) | bytes[i + 3]); } return { w: 400, h: 500 }; } /* Перенос строки по ширине колонки (приблизительно, по числу символов). */ function wrap(text, maxChars) { const words = String(text).split(/\s+/); const lines = []; let line = ''; words.forEach(w => { if ((line + ' ' + w).trim().length > maxChars) { if (line) lines.push(line); line = w; } else line = (line ? line + ' ' : '') + w; }); if (line) lines.push(line); return lines; } /* ---------- сборка PDF ---------- */ async function buildResumePdf(cv) { // фото — необязательно: без него документ тоже валиден let photo = null; if (cv.photo && cv.photoSrc) { try { const buf = await (await fetch(cv.photoSrc)).arrayBuffer(); const bytes = new Uint8Array(buf); photo = { bytes, ...jpegSize(bytes) }; } catch { photo = null; } } const W = 595.28, H = 841.89; // A4 в пунктах const M = 56; // поля const right = W - M; let y = H - M; const ops = []; const text = (str, x, size, font, gray) => { ops.push(`BT /${font} ${size} Tf ${gray != null ? `${gray} g` : '0 g'} ${x} ${y} Td (${pdfEscape(toLatin(str))}) Tj ET`); }; const line = (gray = 0.85) => { ops.push(`${gray} G 0.7 w ${M} ${y} m ${right} ${y} l S`); }; const section = (title) => { y -= 22; text(title.toUpperCase(), M, 8.5, 'F2', 0.45); y -= 6; line(); }; const photoW = 78, photoH = 96; const textRight = photo ? right - photoW - 18 : right; const colChars = Math.floor((textRight - M) / 4.7); // шапка text(cv.name, M, 20, 'F2'); y -= 17; text(cv.title, M, 11, 'F2', 0.24); y -= 14; const contacts = [cv.city, cv.age ? `${cv.age} years old` : null, cv.phone].filter(Boolean).join(' · '); text(contacts, M, 8.5, 'F1', 0.45); const headerBottom = y - 8; // фото справа от шапки if (photo) { const top = H - M + 4; ops.push(`q ${photoW} 0 0 ${photoH} ${right - photoW} ${top - photoH} cm /Im1 Do Q`); } y = Math.min(headerBottom, H - M - photoH - 4); section('Professional summary'); y -= 13; wrap(cv.summary, colChars).forEach(l => { text(l, M, 9.5, 'F1', 0.15); y -= 12.5; }); section('Work experience'); y -= 14; text(cv.title, M, 10, 'F2'); const period = `${2026 - (cv.years || 1)} - present`; ops.push(`BT /F1 8.5 Tf 0.45 g ${right - toLatin(period).length * 4.4} ${y} Td (${pdfEscape(toLatin(period))}) Tj ET`); y -= 12; text(cv.place, M, 9, 'F1', 0.45); y -= 15; cv.bullets.forEach(b => { const ls = wrap(b, colChars - 3); ls.forEach((l, i) => { if (i === 0) { text('•', M, 9.5, 'F1', 0.3); text(l, M + 12, 9.5, 'F1', 0.15); } else text(l, M + 12, 9.5, 'F1', 0.15); y -= 12.5; }); y -= 2; }); section('Skills'); y -= 13; wrap(cv.skills.join(' · '), colChars).forEach(l => { text(l, M, 9.5, 'F1', 0.15); y -= 12.5; }); if (cv.certs.length) { section('Certifications'); y -= 13; cv.certs.forEach(c => { text('— ' + c, M, 9.5, 'F1', 0.15); y -= 12.5; }); } section('Languages'); y -= 13; cv.langs.forEach(l => { text(l.name, M, 9.5, 'F1', 0.15); ops.push(`BT /F1 9.5 Tf 0.45 g ${right - toLatin(l.level).length * 4.7} ${y} Td (${pdfEscape(toLatin(l.level))}) Tj ET`); y -= 12.5; }); // ---- сборка объектов ---- const content = ops.join('\n'); const objs = []; objs[1] = '<< /Type /Catalog /Pages 2 0 R >>'; objs[2] = '<< /Type /Pages /Kids [3 0 R] /Count 1 >>'; objs[3] = `<< /Type /Page /Parent 2 0 R /MediaBox [0 0 ${W.toFixed(2)} ${H.toFixed(2)}] ` + `/Resources << /Font << /F1 5 0 R /F2 6 0 R >>${photo ? ' /XObject << /Im1 7 0 R >>' : ''} >> /Contents 4 0 R >>`; objs[5] = '<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica /Encoding /WinAnsiEncoding >>'; objs[6] = '<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica-Bold /Encoding /WinAnsiEncoding >>'; const parts = []; const offsets = []; let pos = 0; const put = (chunk) => { const b = typeof chunk === 'string' ? enc.encode(chunk) : chunk; parts.push(b); pos += b.length; }; put('%PDF-1.4\n'); const writeObj = (num, dict, stream) => { offsets[num] = pos; put(`${num} 0 obj\n${dict}\n`); if (stream) { put('stream\n'); put(stream); put('\nendstream\n'); } put('endobj\n'); }; writeObj(1, objs[1]); writeObj(2, objs[2]); writeObj(3, objs[3]); writeObj(4, `<< /Length ${enc.encode(content).length} >>`, content); writeObj(5, objs[5]); writeObj(6, objs[6]); if (photo) { writeObj(7, `<< /Type /XObject /Subtype /Image /Width ${photo.w} /Height ${photo.h} ` + `/ColorSpace /DeviceRGB /BitsPerComponent 8 /Filter /DCTDecode /Length ${photo.bytes.length} >>`, photo.bytes); } const count = photo ? 8 : 7; const xref = pos; let table = `xref\n0 ${count}\n0000000000 65535 f \n`; for (let i = 1; i < count; i++) { table += String(offsets[i] || 0).padStart(10, '0') + ' 00000 n \n'; } put(table); put(`trailer\n<< /Size ${count} /Root 1 0 R >>\nstartxref\n${xref}\n%%EOF\n`); const total = parts.reduce((n, p) => n + p.length, 0); const out = new Uint8Array(total); let at = 0; parts.forEach(p => { out.set(p, at); at += p.length; }); return new Blob([out], { type: 'application/pdf' }); } /* ---------- Word (.doc через HTML) ---------- */ async function buildResumeDoc(cv, S) { let img = ''; if (cv.photo && cv.photoSrc) { try { const buf = await (await fetch(cv.photoSrc)).arrayBuffer(); let bin = ''; new Uint8Array(buf).forEach(b => { bin += String.fromCharCode(b); }); img = ``; } catch { img = ''; } } const esc = (s) => String(s).replace(/&/g, '&').replace(/ ${esc(cv.name)} ${img}

${esc(cv.name)}

${esc(cv.title)}
${[cv.city, cv.age, cv.phone].filter(Boolean).map(esc).join(' · ')}

${esc(S.summary)}

${esc(cv.summary)}

${esc(S.exp)}

${esc(cv.title)} — ${esc(cv.place)}
${2026 - (cv.years || 1)} – ${esc(S.present)}

${esc(S.skills)}

${cv.skills.map(esc).join(' · ')}

${cv.certs.length ? `

${esc(S.certs)}

` : ''}

${esc(S.langs)}

${cv.langs.map(l => ``).join('')}
${esc(l.name)}${esc(l.level)}
`; return new Blob(['' + html], { type: 'application/msword' }); } /* Отдать файл пользователю. */ function saveBlob(blob, filename) { const url = URL.createObjectURL(blob); const a = document.createElement('a'); a.href = url; a.download = filename; document.body.appendChild(a); a.click(); setTimeout(() => { URL.revokeObjectURL(url); a.remove(); }, 1500); } function cvFileName(cv, ext) { return (toLatin(cv.name).trim().replace(/\s+/g, '_') || 'CV') + '_CV.' + ext; } Object.assign(window, { buildResumePdf, buildResumeDoc, saveBlob, cvFileName });