/** * Branded AGB PDF — renders the stored legal document HTML (RichTextEditor * output: h2/h3/p/ul/ol/li/strong/em/u/br) with the LogYou quote/contract * brand frame (header/footer/logos from quotePdf.ts). * * The converter is intentionally tiny: it only understands the tags the * in-app editor produces. Unknown tags are unwrapped, their text kept. */ import { BRAND, buildBrandHeader, buildBrandFooter, getQuoteLogos } from './quotePdf' import { getLegalDocumentWithFallback } from '../legalDocsDb' const decodeEntities = (s: string): string => String(s) .replace(/ /g, ' ') .replace(/&/g, '&') .replace(/</g, '<') .replace(/>/g, '>') .replace(/"/g, '"') .replace(/'|'/g, "'") .replace(/&#(\d+);/g, (_, n) => String.fromCharCode(Number(n))) /** Inline HTML → pdfmake text runs (bold/italics/underline). */ const inlineRuns = (html: string): any[] => { const runs: any[] = [] const stack: string[] = [] const re = /<\/?([a-z0-9]+)[^>]*>|[^<]+/gi let m: RegExpExecArray | null while ((m = re.exec(html))) { const token = m[0] if (token.startsWith('<')) { const tag = m[1].toLowerCase() const closing = token.startsWith('= 0) stack.splice(i, 1) } else stack.push(tag) } continue } const text = decodeEntities(token.replace(/\s+/g, ' ')) if (!text) continue const run: any = { text } if (stack.includes('strong') || stack.includes('b')) run.bold = true if (stack.includes('em') || stack.includes('i')) run.italics = true if (stack.includes('u')) run.decoration = 'underline' if (stack.includes('s')) run.decoration = 'lineThrough' runs.push(run) } // trim leading/trailing whitespace of the paragraph if (runs.length) { runs[0].text = String(runs[0].text).replace(/^\s+/, '') runs[runs.length - 1].text = String(runs[runs.length - 1].text).replace(/\s+$/, '') } return runs.filter((r) => r.text !== '') } /** Block-level HTML → pdfmake content array. */ export const htmlToPdfmakeBlocks = (html: string, opts: { tocItems?: boolean } = {}): any[] => { const out: any[] = [] const src = String(html || '') .replace(//g, '') .replace(//gi, '') .replace(//gi, '') const blockRe = /<(h1|h2|h3|h4|p|ul|ol|blockquote)\b[^>]*>([\s\S]*?)<\/\1>|<(hr)\b[^>]*\/?>|([^<]+)/gi let m: RegExpExecArray | null while ((m = blockRe.exec(src))) { const tag = (m[1] || m[3] || '').toLowerCase() const inner = m[2] || '' if (!tag) { const runs = inlineRuns(m[4] || '') if (runs.length) out.push({ text: runs, margin: [0, 0, 0, 6], alignment: 'justify' }) continue } if (tag === 'hr') { out.push({ canvas: [{ type: 'line', x1: 0, y1: 0, x2: 515, y2: 0, lineWidth: 0.5, lineColor: BRAND.grayLine }], margin: [0, 6, 0, 10] }); continue } if (tag === 'h1' || tag === 'h2') { const runs = inlineRuns(inner) out.push({ stack: [ { canvas: [{ type: 'rect', x: 0, y: 0, w: 26, h: 3, color: BRAND.orange }] }, { text: runs, fontSize: 11.5, bold: true, color: BRAND.navy, characterSpacing: 0.3, margin: [0, 5, 0, 0], ...(opts.tocItems ? { tocItem: true, tocStyle: { fontSize: 9, color: BRAND.text } } : {}) } ], margin: [0, 14, 0, 7], unbreakable: true }) continue } if (tag === 'h3' || tag === 'h4') { out.push({ text: inlineRuns(inner), fontSize: 10, bold: true, color: BRAND.navy, margin: [0, 8, 0, 4] }) continue } if (tag === 'ul' || tag === 'ol') { const items: any[] = [] const liRe = /]*>([\s\S]*?)<\/li>/gi let li: RegExpExecArray | null while ((li = liRe.exec(inner))) { const runs = inlineRuns(li[1].replace(/<\/?(p|div)\b[^>]*>/gi, '')) if (runs.length) items.push({ text: runs, margin: [0, 0, 0, 3] }) } if (items.length) out.push(tag === 'ul' ? { ul: items, markerColor: BRAND.orange, margin: [0, 0, 0, 7] } : { ol: items, margin: [0, 0, 0, 7] }) continue } // p / blockquote const runs = inlineRuns(inner) if (runs.length) out.push({ text: runs, margin: [0, 0, 0, 6], alignment: 'justify', ...(tag === 'blockquote' ? { italics: true, color: BRAND.grayText } : {}) }) } return out } /** Strip to plain text (used for e-mail text fallbacks / search). */ export const htmlToPlainText = (html: string): string => decodeEntities( String(html || '') .replace(/<\/(p|div|h[1-6]|li|tr)>/gi, '\n') .replace(//gi, '\n') .replace(/<[^>]+>/g, '') ).replace(/\n{3,}/g, '\n\n').trim() export const legalDocFilename = (docKey: string, language: string, date?: string): string => { const d = date || new Date().toISOString().slice(0, 10) if (docKey === 'avv') return language === 'en' ? `Data-Processing-Agreement_LogYou_${d}.pdf` : `AVV_LogYou_${d}.pdf` return language === 'en' ? `Terms-and-Conditions_LogYou_${d}.pdf` : `AGB_LogYou_${d}.pdf` } export const agbFilename = (language: string, date?: string): string => legalDocFilename('agb', language, date) const LEGAL_HEADERS: Record = { agb: { de: 'Allgemeine Geschäftsbedingungen', en: 'Terms and Conditions' }, avv: { de: 'Auftragsverarbeitungsvertrag (Art. 28 DSGVO)', en: 'Data Processing Agreement (Art. 28 GDPR)' } } export const buildAgbDocDefinition = (doc: { docKey?: string; title: string; contentHtml: string; language: string; updatedAt?: string | null; version?: number }, logos: { light: string | null; dark: string | null }) => { const hdr = LEGAL_HEADERS[doc.docKey || 'agb'] || LEGAL_HEADERS.agb const language = doc.language === 'en' ? 'en' : 'de' const dateWord = language === 'en' ? 'Version' : 'Stand' const stand = doc.updatedAt ? new Date(doc.updatedAt) : new Date() const dd = String(stand.getDate()).padStart(2, '0') const mm = String(stand.getMonth() + 1).padStart(2, '0') const standLabel = language === 'en' ? `${stand.getFullYear()}-${mm}-${dd}` : `${dd}.${mm}.${stand.getFullYear()}` const content: any[] = [ { text: doc.title, fontSize: 18, bold: true, color: BRAND.navy, margin: [0, 6, 0, 4] }, { text: `${dateWord}: ${standLabel}${doc.version ? ` · v${doc.version}` : ''}`, fontSize: 9, color: BRAND.grayText, margin: [0, 0, 0, 14] }, ...htmlToPdfmakeBlocks(doc.contentHtml) ] return { pageSize: 'A4', pageMargins: [40, 106, 40, 86], info: { title: doc.title, author: 'LogYou GmbH', subject: doc.title }, header: buildBrandHeader(hdr[language], `logyou.de · ${dateWord} ${standLabel}`, logos.dark), footer: buildBrandFooter(language === 'en' ? 'Page' : 'Seite', language === 'en' ? 'of' : 'von'), content, defaultStyle: { font: 'Roboto', fontSize: 9.5, color: BRAND.text, lineHeight: 1.3 } } } const loadPdfMake = async () => { const pdfMake = await import('pdfmake/build/pdfmake.js') const pdfFonts = await import('pdfmake/build/vfs_fonts.js') const pdfMakeInstance: any = (pdfMake as any).default || pdfMake let vfs: any = null if ((pdfFonts as any).pdfMake?.vfs) vfs = (pdfFonts as any).pdfMake.vfs else if ((pdfFonts as any).default?.pdfMake?.vfs) vfs = (pdfFonts as any).default.pdfMake.vfs else if ((pdfFonts as any).default?.vfs) vfs = (pdfFonts as any).default.vfs else if ((pdfFonts as any).vfs) vfs = (pdfFonts as any).vfs else if ((pdfFonts as any).default && Object.keys((pdfFonts as any).default).some((k: string) => k.endsWith('.ttf'))) vfs = (pdfFonts as any).default if (!vfs) throw new Error('Could not find vfs fonts in pdfmake fonts module') pdfMakeInstance.vfs = vfs return pdfMakeInstance } /** Render any pdfmake doc definition to a Buffer (shared by the AGB PDF). */ export const renderPdfmake = async (docDefinition: any): Promise => { const pdfMakeInstance = await loadPdfMake() return await new Promise((resolve, reject) => { try { pdfMakeInstance.createPdf(docDefinition).getBuffer((buf: Buffer) => resolve(buf)) } catch (error) { reject(error) } }) } /** A stored legal document (agb / avv) as a branded PDF (German fallback). */ export const generateLegalDocPdf = async (docKey: string, language: string): Promise<{ buffer: Buffer; filename: string; language: string; version: number; title: string }> => { const doc = getLegalDocumentWithFallback(docKey, language) if (!String(doc.contentHtml || '').trim()) throw new Error(`No ${docKey.toUpperCase()} text available`) const logos = await getQuoteLogos() const buffer = await renderPdfmake(buildAgbDocDefinition(doc, logos)) return { buffer, filename: legalDocFilename(docKey, doc.language, doc.updatedAt ? doc.updatedAt.slice(0, 10) : undefined), language: doc.language, version: doc.version, title: doc.title } } /** The AGB as a branded PDF for the requested language (German fallback). */ export const generateAgbPdf = (language: string) => generateLegalDocPdf('agb', language)