/* ============================================================
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.summary)}
${esc(cv.title)} — ${esc(cv.place)}
${2026 - (cv.years || 1)} – ${esc(S.present)}
${cv.skills.map(esc).join(' · ')}
${cv.certs.length ? `| ${esc(l.name)} | ${esc(l.level)} |