caterium-app/public/core/proposal-layout.js

296 lines
33 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/* Client proposals. A4 layouts inspired by the original v17.6.7 covers.
* Coordinates are shared by preview and download; text is measured after local fonts load.
*/
(()=>{
'use strict';
const W=1000,H=1414,M=70,BOTTOM=1310,SCALE=2.4;
const SANS='"Caterium Manrope",Arial,sans-serif';
const SERIF='"Caterium Playfair",Georgia,serif';
const ITALIC='"Caterium Playfair Italic",Georgia,serif';
const definitions=[
['light','Минимализм','split','#fffdf8','#20362d','#98713b','serif','rows'],
['midnight-glass','Luxury Dark','night','#111916','#f6f1e5','#d4b272','serif','rows'],
['editorial-grid','Editorial Magazine','editorial','#f6f3ec','#183d30','#94743c','serif','cards'],
['warm-sun','Warm Sun','sun','#fff4d8','#51361e','#9a5b1b','serif','rows'],
['bento-cards','Bento Cards','bento','#f3f2eb','#27382b','#8b6022','sans','cards'],
['event-story','Event Story','story','#f4ece0','#523a2b','#92643c','serif','rows'],
['black-gold','Food First','photo','#10100e','#fff7e9','#d8b671','serif','cards'],
['personal-letter','Personal Letter','letter','#f7f1e6','#473b2d','#8b673b','italic','rows'],
['event-ticket','Event Ticket','ticket','#eef2f3','#203f53','#38677a','sans','rows'],
['solar-experience','Solar Experience','solar','#ffd54f','#28392c','#77521f','sans','cards'],
['midnight-compact','Ночной минимализм','compact','#122630','#edf4f4','#9cbec4','sans','compact'],
['emerald-gold','Изумрудная классика','frame','#123b31','#f8f2df','#d1b578','serif','rows'],
['neon-emerald','Изумрудный акцент','neon-vertical','#071f1d','#edfff6','#7debaf','sans','compact'],
['cream-elegance','Кремовая классика','arch','#faf3e6','#35432e','#917445','serif','cards'],
['neon-menu','Неоновое меню','neon','#111a15','#f1ffef','#a1ed92','sans','compact'],
['emerald-circles','Изумрудные круги','circles','#102e2c','#f0f6ee','#d3b779','serif','cards'],
['midnight-checklist','Тёмный чек-лист','checklist','#12241d','#f1f5ea','#d7c593','sans','rows'],
['gourmet-hero','Гастро-витрина','gallery','#181611','#fff6e6','#dbb775','serif','cards'],
['diamond-gold','Изумруд и золото','diamond','#133329','#fff6de','#dabe7b','serif','rows'],
['premium-dark','Премиум тёмный','panorama','#141820','#f7f2e9','#cfb18a','sans','rows'],
['premium-emerald','Премиум изумрудный','botanical','#0e382e','#f5f2df','#b8d19c','italic','cards']
];
const THEMES=Object.fromEntries(definitions.map(([id,name,cover,bg,ink,accent,type,menu])=>[id,{id,name,cover,bg,ink,accent,type,menu,dark:!['light','editorial-grid','warm-sun','bento-cards','event-story','personal-letter','event-ticket','solar-experience','cream-elegance'].includes(id)}]));
const IDs=Object.keys(THEMES);
const CURATED_IDS=['light','editorial-grid','midnight-glass','event-story','bento-cards','event-ticket'];
const names={'light':'Светлая классика','editorial-grid':'Редакционный','midnight-glass':'Вечерний','event-story':'Тёплый приём','bento-cards':'Гастро','event-ticket':'Приглашение'};
// Resolve old choices without rewriting orders or their saved snapshots.
const aliases={'warm-sun':'event-story','black-gold':'midnight-glass','personal-letter':'event-story','solar-experience':'bento-cards','midnight-compact':'midnight-glass','emerald-gold':'midnight-glass','neon-emerald':'midnight-glass','cream-elegance':'light','neon-menu':'bento-cards','emerald-circles':'midnight-glass','midnight-checklist':'midnight-glass','gourmet-hero':'bento-cards','diamond-gold':'midnight-glass','premium-dark':'midnight-glass','premium-emerald':'midnight-glass'};
const resolveTemplate=id=>CURATED_IDS.includes(id)?id:aliases[id]||'light';
const number=v=>Number.isFinite(Number(v))?Number(v):0;
const money=v=>Math.round(number(v)).toLocaleString('ru-RU')+' ₽';
const qty=v=>number(v).toLocaleString('ru-RU',{maximumFractionDigits:3});
const date=v=>{if(!v)return '';const d=new Date(String(v)+'T12:00:00');return Number.isNaN(d.getTime())?String(v):d.toLocaleDateString('ru-RU',{day:'numeric',month:'long',year:'numeric'})};
let fontsPromise;
async function ready(){
if(!fontsPromise)fontsPromise=(async()=>{
if(!window.FontFace||!document.fonts)return;
const defs=[['Caterium Manrope','Manrope.ttf','200 800'],['Caterium Playfair','PlayfairDisplay.ttf','400 900'],['Caterium Playfair Italic','PlayfairDisplay-Italic.ttf','400 900']];
await Promise.all(defs.map(async([family,file,weight])=>{
const face=new FontFace(family,`url("${new URL('fonts/'+file,document.baseURI)}")`,{weight});
try{await Promise.race([face.load(),new Promise((_,reject)=>setTimeout(()=>reject(new Error('Font timeout')),8000))]);document.fonts.add(face)}catch(e){console.warn('Proposal font fallback:',family,e.message)}
}));
// Other app/UI webfonts may still be loading indefinitely. Only the
// three awaited proposal faces determine this canvas layout.
})();
return fontsPromise;
}
function loadImage(src){return new Promise(resolve=>{
if(!/^data:image\/(?:png|jpe?g|webp);base64,/i.test(String(src||'')))return resolve(null);
const image=new Image();let done=false;
const finish=v=>{if(done)return;done=true;clearTimeout(timer);resolve(v)};
const timer=setTimeout(()=>finish(null),4000);image.onload=()=>finish(image);image.onerror=()=>finish(null);image.src=src;
})}
function split(ctx,value,width){
const out=[];
for(const para of String(value??'').split(/\r?\n/)){
if(!para.trim()){out.push('');continue}
let line='';
for(const word of para.trim().split(/\s+/)){
if(ctx.measureText((line?line+' ':'')+word).width<=width){line+=(line?' ':'')+word;continue}
if(line){out.push(line);line=''}
for(const char of word){if(line&&ctx.measureText(line+char).width>width){out.push(line);line=''}line+=char}
}
if(line)out.push(line);
}
return out;
}
const defaults={showClientName:true,showDate:true,showGuests:true,showGallery:true,showPriceBreakdown:true,showAmountPerGuest:true,showBoxCount:true,showControl:true,showExtras:true,showFooterNote:true,showFinalPhoto:true,metricMode:'weight',texts:{}};
function settings(s){return {...defaults,...s.pdfSettings,texts:{...defaults.texts,...s.pdfSettings?.texts,...s.offerOverrides?.texts}}}
function painter(canvas,theme){
const ctx=canvas.getContext('2d',{alpha:false});const scale=canvas.width/W;ctx.setTransform(scale,0,0,scale,0,0);
const boxes=canvas.__proposalLayout||[];canvas.__proposalLayout=boxes;
const body=(size=19,weight=400)=>`${weight} ${size}px ${SANS}`;
const display=(size=54)=>`${theme.type==='sans'?700:500} ${size}px ${theme.type==='sans'?SANS:theme.type==='italic'?ITALIC:SERIF}`;
function rect(x,y,w,h,color,r=0){ctx.fillStyle=color;ctx.beginPath();ctx.roundRect(x,y,w,h,r);ctx.fill()}
function line(x,y,w,color=theme.accent){rect(x,y,w,1,color)}
function lines(value,w,font){ctx.font=font;return split(ctx,value,w)}
function write(value,x,y,w,{size=19,weight=400,color=theme.ink,font=body(size,weight),lh=size*1.42,align='left',role='text'}={}){
const values=Array.isArray(value)?value:lines(value,w,font);ctx.save();ctx.font=font;ctx.textBaseline='top';ctx.textAlign=align;ctx.fillStyle=color;
const xx=align==='right'?x+w:align==='center'?x+w/2:x;
values.forEach((v,i)=>ctx.fillText(v,xx,y+i*lh));ctx.restore();
boxes.push({x,y,w,h:values.length*lh,role,text:values.join('\n'),font});return values.length*lh;
}
function fit(value,x,y,w,h,{size=54,min=18,font,displayFont=true,...opts}={}){
let f,ls,lh;
do{f=font|| (displayFont?display(size):body(size,opts.weight||500));lh=size*1.27;ls=lines(value,w,f);if(ls.length*lh<=h)break;size-=1}while(size>=min);
// Cover copy is repeated in the flowing details when unusually long.
if(ls.length*lh>h){canvas.__proposalOverflow=canvas.__proposalOverflow||[];canvas.__proposalOverflow.push(String(value));return write('Подробности на следующих страницах',x,y,w,{size:18,lh:25,...opts})}
return write(ls,x,y,w,{...opts,font:f,size,lh});
}
function photo(img,x,y,w,h,{radius=0,shape='rect',contain=false}={}){
ctx.save();ctx.beginPath();if(shape==='circle')ctx.ellipse(x+w/2,y+h/2,w/2,h/2,0,0,Math.PI*2);else if(shape==='diamond'){ctx.moveTo(x+w/2,y);ctx.lineTo(x+w,y+h/2);ctx.lineTo(x+w/2,y+h);ctx.lineTo(x,y+h/2);ctx.closePath()}else ctx.roundRect(x,y,w,h,radius);ctx.clip();
rect(x,y,w,h,theme.dark?'#263e34':'#e9e3d7');
if(img){const scale=(contain?Math.min:Math.max)(w/img.width,h/img.height);ctx.drawImage(img,x+(w-img.width*scale)/2,y+(h-img.height*scale)/2,img.width*scale,img.height*scale)}
ctx.restore();
}
return {ctx,body,display,rect,line,lines,write,fit,photo};
}
function page(t,kind){const c=document.createElement('canvas');c.width=W*(t.scale||SCALE);c.height=Math.round(H*(t.scale||SCALE));c.dataset.sunProposalTemplate=t.id;c.dataset.sunProposalPage=kind;const d=painter(c,t);d.rect(0,0,W,H,t.bg);return {c,...d}}
function logoForDocument(image,t){
if(!image)return null;
let canvas;try{canvas=window.CateriumBranding.prepareLogoImage(image)}catch(_){return null}
const ctx=canvas.getContext('2d',{willReadFrequently:true}),pixels=ctx.getImageData(0,0,canvas.width,canvas.height).data;
const linear=v=>{v/=255;return v<=.04045?v/12.92:((v+.055)/1.055)**2.4};
const luminance=(r,g,b)=>.2126*linear(r)+.7152*linear(g)+.0722*linear(b);
const rgb=t.bg.match(/[a-f\d]{2}/gi).map(v=>parseInt(v,16)),bg=luminance(...rgb);
let light=0,dark=0,weak=0,count=0;
for(let i=0;i<pixels.length;i+=4){const a=pixels[i+3]/255;if(a<.1)continue;const l=luminance(pixels[i],pixels[i+1],pixels[i+2]);count+=a;if((Math.max(l,bg)+.05)/(Math.min(l,bg)+.05)<2)weak+=a;if(l>.4)light+=a;else dark+=a}
// A full masthead, never a square tile, provides contrast for monochrome logos.
const alternate=count&&weak/count>.55;
return {canvas,bg:alternate?(light>dark?'#17251f':'#fffdf8'):t.bg,ink:alternate?(light>dark?'#fffdf8':'#20362d'):t.ink};
}
function brand(d,s,t,logo,{x=M,y=48,w=860}={}){
let ink=t.ink;
if(logo){
ink=logo.ink;
if(logo.bg!==t.bg)d.rect(0,0,W,y+84,logo.bg);
const image=logo.canvas,maxW=Math.min(280,w*.44),scale=Math.min(maxW/image.width,76/image.height),lw=image.width*scale,lh=image.height*scale;
const yy=y+(76-lh)/2;d.ctx.drawImage(image,x,yy,lw,lh);
(d.c.__proposalImages??=[]).push({role:'logo',x,y:yy,w:lw,h:lh,sourceWidth:image.width,sourceHeight:image.height,background:logo.bg});
x+=lw+32;w-=lw+32;
}
d.fit(s.brandName||'Моя компания',x,y+17,w,57,{size:23,min:15,displayFont:false,weight:700,color:ink});
}
function facts(s,cfg){return [cfg.showDate?[date(s.date),s.time].filter(Boolean).join(' · '):'',cfg.showGuests&&number(s.guests)?`${qty(s.guests)} гостей`:''].filter(Boolean)}
function cover(s,t,images,logo,cfg){
const d=page(t,'cover'),hero=images.find(Boolean),hero2=images.filter(Boolean)[1]||hero,hero3=images.filter(Boolean)[2]||hero;
const title=s.event||'Ваше мероприятие',client=cfg.showClientName?s.client||'':'',eyebrow=cfg.texts.proposalLabel||'Индивидуальное предложение';
const ink=t.ink,accent=t.accent;
const heading=(x,y,w,h=250,size=64)=>d.fit(title,x,y,w,h,{size});
const sub=(x,y,w,h=94)=>d.fit(client,x,y,w,h,{size:25,displayFont:false});
const label=(x,y,w=860)=>d.fit(eyebrow,x,y,w,54,{size:18,displayFont:false,color:accent});
const fact=(x,y,w=860)=>d.fit(facts(s,cfg).join(' / '),x,y,w,65,{size:21,displayFont:false});
const note=(x,y,w,h=140)=>d.fit(cfg.texts.heroNote||'Меню, подобранное для вашего события.',x,y,w,h,{size:22,displayFont:false,color:ink});
const total=(x,y,w=400)=>{d.fit(cfg.texts.totalPriceTitle||'Итоговая стоимость',x,y,w,50,{size:17,displayFont:false,color:accent});d.fit(money(s.pricing?.total),x,y+50,w,100,{size:52})};
if(!['night','photo'].includes(t.cover))brand(d,s,t,logo);
switch(t.cover){
case 'split':
label(M,198,400);heading(M,265,414,290,62);sub(M,582,395);d.line(M,718,370);note(M,758,380);d.photo(hero,520,195,410,760,{radius:180});fact(M,1010);total(M,1120);break;
case 'night':
d.photo(hero,482,155,518,H-155);d.rect(0,0,482,H,t.bg);brand(d,s,t,logo);label(M,235,350);heading(M,310,350,310,58);sub(M,650,345);fact(M,835,345);d.line(M,1000,340);total(M,1050,350);break;
case 'editorial':{
label(M,180);const bottom=244+heading(M,244,840,200,72),photoY=Math.max(435,bottom+66);d.line(M,photoY-32,860);d.photo(hero,414,photoY,516,1170-photoY);d.write('МЕНЮ / 01',M,photoY+4,290,{size:19,weight:700,color:accent});sub(M,photoY+95,280,145);fact(M,photoY+290,290);total(M,1040,310);break;}
case 'sun':
d.rect(758,180,172,172,'#f2c55b',86);label(M,200,550);heading(M,280,630,250,64);sub(M,562,780);d.photo(hero,320,698,610,390,{radius:180});fact(M,649,850);total(M,1140,530);break;
case 'bento':
label(M,180);heading(M,255,850,185,67);sub(M,465,800,65);note(M,545,850,52);d.photo(hero,M,635,556,405,{radius:26});d.photo(hero2,650,635,280,190,{radius:24});d.photo(hero3,650,850,280,190,{radius:24});fact(M,1080);total(M,1160,740);break;
case 'story':
label(M,182);heading(M,252,835,185,68);sub(M,470,820,75);note(M,556,840,64);d.line(M,670,860);['Меню','Подготовка','Ваше событие'].forEach((v,i)=>{const x=M+i*310;d.rect(x,662,16,16,accent,8);d.write(v,x,697,240,{size:18})});d.photo(hero,M,757,860,340);fact(M,1140,410);total(553,1130,377);break;
case 'photo':{
d.photo(hero,0,0,W,H);const g=d.ctx.createLinearGradient(0,0,0,H);g.addColorStop(0,'rgba(0,0,0,.82)');g.addColorStop(.48,'rgba(0,0,0,.24)');g.addColorStop(1,'rgba(0,0,0,.95)');d.ctx.fillStyle=g;d.ctx.fillRect(0,0,W,H);brand(d,s,t,logo);label(M,215);heading(M,290,800,270,74);sub(M,620,800);fact(M,1010);total(M,1120,820);break;}
case 'letter':
d.rect(44,162,912,1125,'#fffcf5',4);label(94,218,700);d.fit(client?`${client},`:'Дорогие гости,',94,308,760,165,{size:56});d.write('Предложение для вашего мероприятия',94,496,730,{size:22});heading(94,549,730,170,44);note(94,761,470,155);d.photo(hero,690,769,210,210,{shape:'circle'});fact(94,1010,790);total(94,1100,570);break;
case 'ticket':
d.rect(M,190,860,1080,'#fbfcfa',18);d.rect(740,190,190,1080,'#dbe8ea',18);d.ctx.save();d.ctx.setLineDash([7,9]);d.ctx.strokeStyle=accent;d.ctx.beginPath();d.ctx.moveTo(716,215);d.ctx.lineTo(716,1240);d.ctx.stroke();d.ctx.restore();label(106,246,570);heading(106,329,560,220,59);sub(106,590,550,94);note(106,710,550,64);fact(106,822,550);d.photo(hero,106,930,255,180,{radius:10});total(390,946,280);d.write('МЕНЮ',762,268,150,{size:17,weight:700});if(cfg.showDate){d.fit(date(s.date),765,341,140,180,{size:26,displayFont:false});d.fit(s.time||'',765,570,140,80,{size:34,displayFont:false})}break;
case 'solar':
label(M,205);heading(M,276,460,300,64);d.photo(hero,571,390,359,359,{shape:'circle'});sub(M,649,435,150);d.line(M,860,860);fact(M,913);d.rect(M,1040,860,238,'#fff1b5',30);total(106,1080,780);break;
case 'compact':
label(M,212);heading(M,300,850,250,70);sub(M,590,820);fact(M,760);d.line(M,867,860);d.photo(hero,550,935,380,330);total(M,980,420);break;
case 'frame':
d.ctx.strokeStyle=accent;d.ctx.lineWidth=1;d.ctx.strokeRect(38,158,924,1140);label(96,227,808);d.fit(title,96,313,808,255,{size:67,align:'center'});d.fit(client,96,604,808,90,{size:24,displayFont:false,align:'center'});d.photo(hero,240,750,520,300,{radius:160});fact(96,1103,810);total(96,1175,810);break;
case 'neon-vertical':
d.rect(M,218,12,910,accent);label(114,218,815);heading(114,300,800,240,72);sub(114,571,780);d.photo(hero,114,750,390,390,{radius:195});d.photo(hero2,536,750,390,390,{radius:195});fact(114,668,800);total(114,1170,790);break;
case 'arch':
label(M,189);d.fit(title,M,265,860,205,{size:65,align:'center'});d.fit(client,M,490,860,94,{size:24,displayFont:false,align:'center'});d.photo(hero,244,648,512,445,{radius:[250,250,10,10]});fact(M,1140,420);total(555,1136,375);break;
case 'neon':
d.rect(M,190,860,328,accent,24);d.fit(eyebrow,105,222,790,54,{size:18,displayFont:false,color:'#142019'});d.fit(title,105,290,790,200,{size:62,color:'#142019'});sub(M,558,800);fact(M,660);d.photo(hero,M,785,520,425,{radius:24});d.photo(hero2,616,785,314,205,{radius:24});total(616,1040,314);break;
case 'circles':
label(M,213);heading(M,291,820,225,68);sub(M,550,820);d.photo(hero,M,731,400,400,{shape:'circle'});d.photo(hero2,513,711,256,256,{shape:'circle'});d.photo(hero3,701,919,229,229,{shape:'circle'});fact(M,646);total(M,1165,800);break;
case 'checklist':
label(M,212);heading(M,294,850,240,67);sub(M,568,810);fact(M,680);d.line(M,786,860);(s.items||[]).slice(0,3).forEach((it,i)=>{const y=843+i*99;d.write(String(i+1).padStart(2,'0'),M,y,70,{size:24,color:accent});d.fit(it.name,170,y,750,77,{size:24,displayFont:false})});total(M,1170,810);break;
case 'gallery':
label(M,203);heading(M,280,840,210,68);sub(M,528,810);[hero,hero2,hero3].forEach((im,i)=>d.photo(im,M+i*294,707+i*37,272,355,{radius:136}));fact(M,626);total(M,1160,830);break;
case 'diamond':
label(M,200);heading(M,284,840,220,66);sub(M,537,810);fact(M,648);d.photo(hero,430,735,500,455,{shape:'diamond'});d.write('МЕНЮ',M,879,225,{size:18,color:accent});total(M,969,330);break;
case 'panorama':
d.photo(hero,0,176,W,467);label(M,701);heading(M,778,840,220,65);sub(M,1040,430,140);fact(M,1209,410);total(550,1135,380);break;
case 'botanical':
label(M,199,460);heading(M,284,470,350,64);d.photo(hero,590,205,340,590,{radius:170});sub(M,686,460,155);d.line(M,901,860);note(M,958,475,140);fact(M,1150,445);total(568,1119,362);break;
}
return d.c;
}
function composition(item){const values=item.compositionTotal?.length?item.compositionTotal:item.composition||[];return (Array.isArray(values)?values:[values]).map(v=>typeof v==='string'?v:v?.name||'').filter(Boolean).join(' · ')}
async function renderPages(s){
const cfg=settings(s),items=s.items||[],t={...THEMES[resolveTemplate(s.offerTemplateId)],scale:items.length>24?2:SCALE},cache=new Map();
const img=src=>{if(!src)return Promise.resolve(null);if(!cache.has(src))cache.set(src,loadImage(src));return cache.get(src)};
await ready();
const [rawLogo,images,gallery]=await Promise.all([img(s.logo),Promise.all(items.map(i=>img((i.photoData||i.photo)===s.logo?'':i.photoData||i.photo))),Promise.all((s.finalGallery||[]).map(img))]);
const logo=logoForDocument(rawLogo,t);
const covers=[cover(s,t,images,logo,cfg)],pages=[...covers];let d,y;
const muted=t.dark?'#d1d6ce':'#515b50',lineColor=t.dark?'#426055':'#d9d2c4',panel=t.dark?'#203b30':'#ffffff';
// A sunny cover is paired with warm paper inside, making long menus easier to read.
const inner={...t,bg:t.id==='solar-experience'?'#fff9e8':t.bg};
function newPage(title){d=page(inner,'content');pages.push(d.c);brand(d,s,t,logo,{y:48});d.line(M,135,860,lineColor);y=172;if(title){y+=d.write(title,M,y,860,{size:36,font:d.display(36),lh:46});y+=28}}
function ensure(h,title){if(!d||y+h>BOTTOM)newPage(title)}
function flow(value,{size=19,color=t.ink,font,role='text',gap=20}={}){
if(!String(value||'').trim())return;
ensure(size*1.45+gap);const lines=d.lines(value,860,font||d.body(size));let pos=0;const lh=size*1.45;
while(pos<lines.length){let count=Math.floor((BOTTOM-y-gap)/lh);if(count<1){newPage();count=Math.floor((BOTTOM-y-gap)/lh)}const part=lines.slice(pos,pos+count);y+=d.write(part,M,y,860,{font:font||d.body(size),size,lh,color,role});pos+=part.length;if(pos<lines.length)newPage()}
y+=gap;
}
const title=cfg.texts.menuTitle||'Меню';newPage(title);
for(const value of covers[0].__proposalOverflow||[])flow(value,{size:22});
const meta=it=>[it.categoryName,it.weight?`Вес: ${it.weight}`:'',number(it.pieces)>0?`${qty(it.pieces)} шт. в порции`:''].filter(Boolean).join(' · ');
function priceLine(it){return `${qty(it.qty)} × ${money(it.unitPrice)} = ${money(it.sum)}`}
function measure(it,width,card){return d.lines(it.name||'Позиция меню',width,d.body(card?25:24,700)).length*(card?33:32)+10+(meta(it)?d.lines(meta(it),width,d.body(18)).length*26+10:0)+(composition(it)?d.lines(composition(it),width,d.body(20)).length*29:0)}
function details(it,x,yy,width,card){
yy+=d.write(it.name||'Позиция меню',x,yy,width,{size:card?25:24,weight:700,lh:card?33:32,role:'item-name'})+10;
if(meta(it))yy+=d.write(meta(it),x,yy,width,{size:18,lh:26,color:muted,role:'item-meta'})+10;
if(composition(it))yy+=d.write(composition(it),x,yy,width,{size:20,lh:29,color:muted,role:'composition'});
return yy;
}
function tallItem(it,index){
if(y>280)newPage(title+' · продолжение');
flow(it.name||'Позиция меню',{size:25,font:d.body(25,700),role:'item-name'});flow(meta(it),{size:18,color:muted});flow(composition(it),{size:20,color:muted,role:'composition'});flow(priceLine(it),{size:21,role:'item-price'});d.line(M,y-4,860,lineColor);y+=24;
}
if(!items.length)flow('Позиции меню пока не добавлены.',{color:muted});
if(t.id==='event-story'){
for(let index=0;index<items.length;index++){
const it=items[index],photoW=cfg.showGallery&&images[index]?190:0,x=M+24+(photoW?photoW+28:0),width=906-x;
const h=Math.max(photoW?138:0,measure(it,width,false))+90;
if(h>1060){tallItem(it,index);continue}
ensure(h+20,title+' · продолжение');d.rect(M,y,860,h,'#fffaf1',12);
if(photoW)d.photo(images[index],M+24,y+24,photoW,138,{radius:6});
details(it,x,y+24,width,false);const priceY=y+h-44;
d.line(x,priceY-10,width,lineColor);d.write(`${qty(it.qty)} × ${money(it.unitPrice)}`,x,priceY,width*.52,{size:18,color:muted,role:'quantity-price'});
d.write(money(it.sum),x+width*.55,priceY,width*.45,{size:23,weight:700,align:'right',role:'item-price'});y+=h+20;
}
}else if(t.menu==='cards'){
const cw=415,gap=30,photoH=cfg.showGallery?160:0;
for(let index=0;index<items.length;){
const pair=items.slice(index,index+2),heights=pair.map(it=>measure(it,cw-40,true)+photoH+132);
if(heights.some(h=>h>1080)){tallItem(items[index],index);index++;continue}
const h=Math.max(...heights);ensure(h+24,title+' · продолжение');
pair.forEach((it,k)=>{const x=M+k*(cw+gap),r=t.id==='editorial-grid'?0:16;d.rect(x,y,cw,h,t.id==='editorial-grid'?t.bg:panel,r);if(photoH)d.photo(images[index+k],x,y,cw,photoH,{radius:[r,r,0,0]});details(it,x+20,y+photoH+24,cw-40,true);const priceY=y+h-75;d.line(x+20,priceY-15,cw-40,lineColor);d.write(`${qty(it.qty)} × ${money(it.unitPrice)}`,x+20,priceY,cw-40,{size:18,color:muted,role:'quantity-price'});d.write(money(it.sum),x+20,priceY+28,cw-40,{size:23,weight:700,align:'right',role:'item-price'})});y+=h+24;index+=pair.length;
}
}else{
const photoW=cfg.showGallery&&images.some(Boolean)?(t.id==='event-ticket'?90:t.id==='midnight-glass'?120:145):0,tx=M+(photoW?photoW+25:0),tw=860-(tx-M)-185;
const rowHeight=it=>Math.max(photoW,measure(it,tw,false)+40,d.lines(money(it.sum),176,d.body(23,700)).length*32+12+d.lines(`${qty(it.qty)} × ${money(it.unitPrice)}`,176,d.body(16)).length*23)+42;
const totalMenuHeight=items.reduce((sum,it)=>sum+rowHeight(it),0),capacity=BOTTOM-y;
const target=totalMenuHeight/Math.max(1,Math.ceil(totalMenuHeight/Math.max(1,capacity)));let menuStart=y;
for(let index=0;index<items.length;index++){
const it=items[index],amountLines=d.lines(money(it.sum),176,d.body(23,700)),amountH=amountLines.length*32,unitLines=d.lines(`${qty(it.qty)} × ${money(it.unitPrice)}`,176,d.body(16)),h=Math.max(photoW,measure(it,tw,false)+40,amountH+12+unitLines.length*23)+42;
if(h>1080){tallItem(it,index);continue}
// Balance ordinary rows across pages instead of leaving a short tail page.
if(target>0&&y>menuStart&&y+h>menuStart+target+h*.4){newPage(title+' · продолжение');menuStart=y}
ensure(h,title+' · продолжение');if(t.id==='event-ticket'&&index%2===0)d.rect(M-12,y-12,884,h-20,'#e3ebec',6);if(photoW)d.photo(images[index],M,y,photoW,photoW,{radius:t.id==='midnight-glass'?photoW/2:6});details(it,tx,y,tw,false);
d.write(amountLines,754,y,176,{size:23,lh:32,weight:700,align:'right',role:'item-price'});
d.write(unitLines,754,y+amountH+12,176,{size:16,lh:23,color:muted,align:'right',role:'quantity-price'});
d.line(M,y+h-23,860,lineColor);y+=h;
}
}
const p=s.pricing||{},text=cfg.texts;
const rows=cfg.showPriceBreakdown?[[text.priceItemsLabel||'Стоимость позиций',p.base],...(number(p.manual)?[[text.discountLabel||'Скидка',-number(p.manual)]]:[]),...(number(p.promo)?[[`${text.promoLabel||'Промокод'}${s.promoCode?' '+s.promoCode:''}`,-number(p.promo)]]:[]),[text.menuAfterDiscountLabel||'Меню после скидок',p.itemsTotal],[text.deliveryLabel||'Доставка',p.delivery]]:[];
// Keep the calculation and its total together when they fit on one page.
const priceRowHeight=([label,value])=>Math.max(d.lines(label,610,d.body(19)).length*28,d.lines(money(value),215,d.body(21,700)).length*29)+22;
const summaryHeight=Math.max(60,d.lines(text.summaryTitle||text.totalLabel||'Итого',380,d.display(29)).length*39,d.lines(money(p.total),460,d.body(35,700)).length*45);
const pricingHeight=22+d.lines(text.pricingTitle||'Расчёт стоимости',860,d.display(34)).length*34*1.45+24+rows.reduce((n,row)=>n+priceRowHeight(row),0)+28+summaryHeight+26+85;
ensure(Math.min(pricingHeight,BOTTOM-172));
y+=22;flow(text.pricingTitle||'Расчёт стоимости',{size:34,font:d.display(34),gap:24});
for(const [label,value] of rows){
const ls=d.lines(label,610,d.body(19)),rs=d.lines(money(value),215,d.body(21,700)),h=Math.max(ls.length*28,rs.length*29)+22;ensure(h+95);
d.write(ls,M,y,610,{size:19,lh:28,color:muted,role:'pricing-label'});d.write(rs,715,y,215,{size:21,weight:700,lh:29,align:'right',role:'pricing-value'});y+=h;
}
ensure(28+summaryHeight+26);d.line(M,y,860,lineColor);y+=28;const totalLabel=d.lines(text.summaryTitle||text.totalLabel||'Итого',380,d.display(29));d.write(totalLabel,M,y,380,{size:29,font:d.display(29),lh:39,role:'total-label'});const amount=d.lines(money(p.total),460,d.body(35,700));y+=Math.max(60,totalLabel.length*39,d.write(amount,470,y,460,{size:35,weight:700,lh:45,align:'right',role:'total-value'}))+26;
const metrics=[];const guests=number(s.guests);
if(cfg.showBoxCount)metrics.push(`${text.boxCountLabel||'Количество боксов'}: ${qty(items.filter(i=>[0,5].includes(number(i.categoryId))).reduce((n,i)=>n+number(i.qty),0))}`);
if(cfg.showGuests&&guests){if(cfg.showAmountPerGuest)metrics.push(`${text.amountPerGuestLabel||'Сумма на гостя'}: ${money(number(p.itemsTotal)/guests)}`);if(cfg.metricMode==='pieces'){if(number(s.foodPieces))metrics.push(`${text.piecesMetricLabel||'Канапе на гостя'}: ${qty(Math.ceil(number(s.foodPieces)/guests))} шт.`)}else if(number(s.foodGrams))metrics.push(`${text.weightMetricLabel||'Вес еды на гостя'}: ${qty(Math.ceil(number(s.foodGrams)/guests/5)*5)} г`)}
if(metrics.length)flow(metrics.join(' · '),{size:17,color:muted});
if(cfg.showFooterNote&&text.footerNote)flow(text.footerNote,{size:17,color:muted,gap:28});
if(text.salesTitle&&text.salesNote){ensure(155);flow(text.salesTitle,{size:27,font:d.display(27),gap:16});flow(text.salesNote,{size:19,gap:28})}
const sections=[[cfg.showControl,text.controlTitle||s.controlTitle||'Организация мероприятия',cfg.controlLines||s.controlLines],[cfg.showExtras,text.extrasTitle||s.extrasTitle||'Дополнительно',cfg.extraServices||s.extraServices]].filter(([enabled,,values])=>enabled&&Array.isArray(values)&&values.length);
const columnH=([,heading,values])=>d.lines(heading,405,d.display(27)).length*36+24+values.reduce((sum,v)=>sum+d.lines('• '+String(v),405,d.body(18)).length*26+12,0);
if(sections.length===2&&sections.every(section=>columnH(section)<650)){
const h=Math.max(...sections.map(columnH));ensure(h+35);sections.forEach(([,heading,values],index)=>{const x=M+index*455;let yy=y;d.line(x,yy,405,lineColor);yy+=18;yy+=d.write(heading,x,yy,405,{size:27,font:d.display(27),lh:36})+18;for(const value of values)yy+=d.write('• '+String(value),x,yy,405,{size:18,lh:26})+12});y+=h+35;
}else for(const [,heading,values] of sections){ensure(135);flow(heading,{size:27,font:d.display(27),gap:16});for(const value of values)flow('• '+String(value),{size:18,gap:10});y+=18}
const pics=gallery.filter(Boolean).slice(0,2);
// Gallery fills existing space; it never creates a nearly empty extra page.
if(cfg.showGallery&&cfg.showFinalPhoto&&pics.length&&BOTTOM-y>=260){const label=text.finalGalleryTitle||'Сервировка вашего события',h=d.lines(label,860,d.display(27)).length*38;const available=BOTTOM-y-h-18;if(available>=180){y+=d.write(label,M,y,860,{size:27,font:d.display(27),lh:38})+18;const w=(860-22*(pics.length-1))/pics.length;pics.forEach((im,i)=>d.photo(im,M+i*(w+22),y,w,Math.min(340,available),{radius:8}))}}
pages.forEach((c,i)=>{const fd=painter(c,i?inner:t);const darkCover=i===0&&t.cover==='photo';if(i===0&&['night','photo'].includes(t.cover))fd.rect(0,1330,W,84,t.bg);fd.line(M,1343,860,darkCover?'#8b8b78':lineColor);fd.fit([s.brandName||'Моя компания',s.brandCity].filter(Boolean).join(' · '),M,s.brandContacts?1351:1361,720,26,{size:14,min:12,displayFont:false,color:darkCover?'#fff5e6':muted});if(s.brandContacts)fd.fit(s.brandContacts,M,1376,720,26,{size:12,min:10,displayFont:false,color:darkCover?'#fff5e6':muted});fd.write(`${i+1} / ${pages.length}`,850,1361,80,{size:14,align:'right',color:darkCover?'#fff5e6':muted,role:'page-number'});});
return pages;
}
const descriptions={split:'Светлая обложка, вертикальное фото и спокойная типографика.',night:'Тёмная полоса с текстом и фотография на всю высоту.',editorial:'Журнальная сетка, выразительные заголовки и карточки меню.',sun:'Тёплая бумага, солнечный акцент и широкая фотография.',bento:'Модульная фотокомпозиция и меню в двух колонках.',story:'История события с этапами подготовки и панорамным фото.',photo:'Фотография на всю обложку и золотистые акценты.',letter:'Личное обращение на светлой бумаге и курсивные заголовки.',ticket:'Обложка в форме приглашения с отдельной полосой даты.',solar:'Яркая солнечная обложка и светлые внутренние страницы.',compact:'Лаконичная ночная палитра и компактный список меню.',frame:'Тонкая золотистая рамка и симметричная композиция.', 'neon-vertical':'Вертикальный акцент и пара круглых фотографий.',arch:'Кремовая бумага, арочное фото и классические заголовки.',neon:'Яркий заголовок на тёмной бумаге и модульные фото.',circles:'Круглые фотографии разных размеров и изумрудная палитра.',checklist:'Нумерованные акценты меню на строгой тёмной обложке.',gallery:'Три фотографии в арках и крупная журнальная типографика.',diamond:'Фотография в ромбе и изумрудно-золотая палитра.',panorama:'Широкая панорама и сдержанные современные заголовки.',botanical:'Высокая арка, курсивные заголовки и глубокий изумрудный фон.'};
window.CateriumProposalPDF=Object.freeze({VERSION:'20260918-proposals-six',IDS:IDs,CURATED_IDS,resolveTemplate,templates:CURATED_IDS.map(id=>({id,name:names[id],desc:descriptions[THEMES[id].cover],thumb:`offer-templates/quality-${id}.jpg`})),ready,renderPages});
})();