chore: bump version to 0.8.95 and update legal documents features
All checks were successful
Deploy Development / deploy (push) Successful in 40s
Test Suite / pytest-backend (push) Successful in 35s
Test Suite / lint-backend (push) Successful in 0s
Test Suite / build-frontend (push) Successful in 15s
Test Suite / playwright-tests (push) Successful in 56s

- Updated app version to 0.8.95 with a new build date of 2026-05-12.
- Enhanced legal documents functionality to support section numbering and Markdown formatting in the output.
- Updated dependencies in package.json to include 'marked' and 'react-markdown'.
- Added new CSS styles for legal document presentation.
- Refactored PDF generation logic to incorporate new metadata and improved document structure.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Lars 2026-05-12 10:52:06 +02:00
parent 04663e090a
commit 81b9e8f601
7 changed files with 533 additions and 163 deletions

View File

@ -1,11 +1,11 @@
# Shinkan Jinkendo Version Information
APP_VERSION = "0.8.94"
BUILD_DATE = "2026-05-11"
APP_VERSION = "0.8.95"
BUILD_DATE = "2026-05-12"
DB_SCHEMA_VERSION = "20260511053"
MODULE_VERSIONS = {
"legal_documents": "1.2.0", # jsPDF-Download auf LegalPage (oeffentlich) + Admin; Abschnitts-Sortierung/-Einfuegen
"legal_documents": "1.3.0", # P-01: Ausgabe §-Nummerierung pro Abschnitt; Markdown im Fließtext + PDF; gem. legalPdfExport
"auth": "1.2.3", # P-05b: reset-password min_length=8 via Pydantic PasswordResetConfirm
"profiles": "1.7.0", # exercise_list_prefs JSONB (Standard Übungsfilter); Patch via ProfileUpdate + Json()
"tenant_context": "1.0.5", # Plattform-Admin: effective_club ohne Header aus Profil active_club_id wenn Verein existiert
@ -34,6 +34,13 @@ MODULE_VERSIONS = {
}
CHANGELOG = [
{
"version": "0.8.95",
"date": "2026-05-12",
"changes": [
"P-01 Rechtstexte: Abschnitte in der Ausgabe mit fortlaufender §1, §2, … (nur Darstellung/PDF, nicht in der DB); Fließtext mit Markdown (react-markdown) inkl. PDF-Rendering (fett/kursiv, Listen, Links, Codeblöcke).",
],
},
{
"version": "0.8.94",
"date": "2026-05-11",

View File

@ -10,9 +10,12 @@
"dependencies": {
"jspdf": "^4.2.1",
"lucide-react": "^0.344.0",
"marked": "^18.0.3",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-router-dom": "^6.22.0"
"react-markdown": "^10.1.0",
"react-router-dom": "^6.22.0",
"remark-breaks": "^4.0.0"
},
"devDependencies": {
"@vitejs/plugin-react": "^4.2.1",

View File

@ -291,6 +291,83 @@ ul > li.card + li.card,
margin-top: 2px;
}
/* Rechtstexte (P-01): Markdown im Fließtext */
.legal-doc-body {
font-size: 0.95rem;
line-height: 1.55;
color: var(--text1);
}
.legal-doc-body--muted {
color: var(--text3);
font-style: italic;
}
.legal-doc-body p {
margin: 0 0 0.65em;
}
.legal-doc-body p:last-child {
margin-bottom: 0;
}
.legal-doc-body ul,
.legal-doc-body ol {
margin: 0.4em 0 0.65em 1.25rem;
padding: 0;
}
.legal-doc-body li {
margin: 0.2em 0;
}
.legal-doc-body strong {
font-weight: 700;
}
.legal-doc-body em {
font-style: italic;
}
.legal-doc-body code {
font-family: ui-monospace, monospace;
font-size: 0.88em;
background: var(--surface2);
padding: 0.1em 0.35em;
border-radius: 4px;
}
.legal-doc-body pre {
background: var(--surface2);
padding: 10px 12px;
border-radius: 8px;
overflow-x: auto;
margin: 0.65em 0;
font-size: 0.85em;
}
.legal-doc-body pre code {
background: none;
padding: 0;
}
.legal-doc-body blockquote {
margin: 0.5em 0;
padding-left: 12px;
border-left: 3px solid var(--border2);
color: var(--text2);
}
.legal-doc-body a {
color: var(--accent);
}
.legal-doc-body h1,
.legal-doc-body h2,
.legal-doc-body h3,
.legal-doc-body h4 {
font-size: 1em;
font-weight: 700;
margin: 0.75em 0 0.35em;
}
.legal-doc-body h1:first-child,
.legal-doc-body h2:first-child,
.legal-doc-body h3:first-child {
margin-top: 0;
}
.legal-doc-body hr {
border: none;
border-top: 1px solid var(--border);
margin: 0.85em 0;
}
.form-input {
width: 100%;
min-width: 0;

View File

@ -0,0 +1,25 @@
import ReactMarkdown from 'react-markdown'
import remarkBreaks from 'remark-breaks'
/**
* Rechtstext-Absatz aus Markdown (ohne Roht-HTML; Links mit target=_blank).
*/
export default function LegalDocumentBody({ content, muted }) {
if (content == null || content === '') return null
return (
<div className={`legal-doc-body ${muted ? 'legal-doc-body--muted' : ''}`}>
<ReactMarkdown
remarkPlugins={[remarkBreaks]}
components={{
a: ({ href, children }) => (
<a href={href} target="_blank" rel="noopener noreferrer">
{children}
</a>
),
}}
>
{content}
</ReactMarkdown>
</div>
)
}

View File

@ -1,90 +1,9 @@
import { useState, useEffect, useCallback } from 'react'
import { jsPDF } from 'jspdf'
import { FileText, Plus, Edit2, Archive, CheckCircle, Clock, Copy, Download, ChevronUp, ChevronDown } from 'lucide-react'
import api from '../utils/api'
import { generateLegalPdf } from '../utils/legalPdfExport'
// PDF generation
function generateLegalPdf(doc) {
const pdf = new jsPDF({ format: 'a4', unit: 'mm' })
const marginL = 22
const marginR = 22
const marginTop = 28
const pageW = 210
const contentW = pageW - marginL - marginR
const bottomLimit = 277 // A4 297mm - 20mm bottom margin
let y = marginTop
const checkBreak = (need) => {
if (y + need > bottomLimit) {
pdf.addPage()
y = marginTop
}
}
// Title
pdf.setFont('helvetica', 'bold')
pdf.setFontSize(20)
pdf.text(doc.title, marginL, y)
y += 10
// Meta line
const STATUS_DE = { published: 'Gültig seit', draft: 'Entwurf — Stand', archived: 'Archiviert — Stand' }
const dateStr = doc.published_at
? new Date(doc.published_at).toLocaleDateString('de-DE')
: new Date(doc.updated_at || doc.created_at).toLocaleDateString('de-DE')
const metaLine = `Version ${doc.version} | ${STATUS_DE[doc.status] || doc.status} ${dateStr}`
pdf.setFont('helvetica', 'normal')
pdf.setFontSize(10)
pdf.setTextColor(90, 90, 90)
pdf.text(metaLine, marginL, y)
y += 3
pdf.setDrawColor(0, 0, 0)
pdf.setLineWidth(0.4)
pdf.line(marginL, y, pageW - marginR, y)
y += 8
pdf.setTextColor(0, 0, 0)
// Sections
for (const section of (doc.content_sections || [])) {
checkBreak(14)
pdf.setFont('helvetica', 'bold')
pdf.setFontSize(11)
pdf.text(section.heading || '', marginL, y)
y += 6
if (section.content) {
pdf.setFont('helvetica', 'normal')
pdf.setFontSize(10)
const lines = pdf.splitTextToSize(section.content, contentW)
for (const line of lines) {
checkBreak(5)
pdf.text(line, marginL, y)
y += 5
}
}
y += 5
}
// Footer on every page
const total = pdf.getNumberOfPages()
for (let i = 1; i <= total; i++) {
pdf.setPage(i)
pdf.setFont('helvetica', 'normal')
pdf.setFontSize(8)
pdf.setTextColor(150, 150, 150)
const fy = 289
pdf.text(
`Shinkan Jinkendo | Exportiert am ${new Date().toLocaleDateString('de-DE')}`,
marginL, fy
)
pdf.text(`Seite ${i} von ${total}`, pageW - marginR, fy, { align: 'right' })
pdf.setTextColor(0, 0, 0)
}
pdf.save(`${doc.document_type}_v${doc.version}.pdf`)
}
const PDF_STATUS_META = { published: 'Gültig seit', draft: 'Entwurf — Stand', archived: 'Archiviert — Stand' }
// Sub-components
@ -191,7 +110,12 @@ function SectionEditor({ sections, onChange }) {
/>
</div>
<div className="form-row">
<label className="form-label">Inhalt</label>
<label className="form-label">
Inhalt
<span className="form-sub">
Einfache Markdown-Formatierung: **fett**, *kursiv*, Listen (- oder 1.), [Link](https://), Zeilenumbruch mit Leerzeile.
</span>
</label>
<textarea
className="form-input" rows={4} value={sec.content}
onChange={e => update(i, 'content', e.target.value)}
@ -454,7 +378,11 @@ export default function AdminLegalDocumentsPage() {
const handleDownload = async (doc) => {
const full = await api.getLegalDocument(doc.id)
generateLegalPdf(full)
const dateStr = full.published_at
? new Date(full.published_at).toLocaleDateString('de-DE')
: new Date(full.updated_at || full.created_at).toLocaleDateString('de-DE')
const metaLine = `Version ${full.version} | ${PDF_STATUS_META[full.status] || full.status} ${dateStr}`
generateLegalPdf(full, metaLine)
}
const publishedDoc = documents.find(d => d.status === 'published')

View File

@ -1,76 +1,9 @@
import { useState, useEffect } from 'react'
import { Link } from 'react-router-dom'
import { jsPDF } from 'jspdf'
import LegalDocumentBody from '../components/LegalDocumentBody'
import { generateLegalPdf, legalSectionNumber } from '../utils/legalPdfExport'
import api from '../utils/api'
function generateLegalPdf(doc) {
const pdf = new jsPDF({ format: 'a4', unit: 'mm' })
const marginL = 22, marginR = 22, marginTop = 28, pageW = 210
const contentW = pageW - marginL - marginR
const bottomLimit = 277
let y = marginTop
const checkBreak = (need) => {
if (y + need > bottomLimit) { pdf.addPage(); y = marginTop }
}
pdf.setFont('helvetica', 'bold')
pdf.setFontSize(20)
pdf.text(doc.title, marginL, y)
y += 10
const dateStr = doc.published_at
? new Date(doc.published_at).toLocaleDateString('de-DE')
: new Date(doc.updated_at || doc.created_at).toLocaleDateString('de-DE')
const metaLine = `Version ${doc.version} | Gueltig seit ${dateStr}`
pdf.setFont('helvetica', 'normal')
pdf.setFontSize(10)
pdf.setTextColor(90, 90, 90)
pdf.text(metaLine, marginL, y)
y += 3
pdf.setDrawColor(0, 0, 0)
pdf.setLineWidth(0.4)
pdf.line(marginL, y, pageW - marginR, y)
y += 8
pdf.setTextColor(0, 0, 0)
for (const section of (doc.content_sections || [])) {
checkBreak(14)
pdf.setFont('helvetica', 'bold')
pdf.setFontSize(11)
pdf.text(section.heading || '', marginL, y)
y += 6
if (section.content) {
pdf.setFont('helvetica', 'normal')
pdf.setFontSize(10)
const lines = pdf.splitTextToSize(section.content, contentW)
for (const line of lines) {
checkBreak(5)
pdf.text(line, marginL, y)
y += 5
}
}
y += 5
}
const total = pdf.getNumberOfPages()
for (let i = 1; i <= total; i++) {
pdf.setPage(i)
pdf.setFont('helvetica', 'normal')
pdf.setFontSize(8)
pdf.setTextColor(150, 150, 150)
const fy = 289
pdf.text(
`Shinkan Jinkendo | Exportiert am ${new Date().toLocaleDateString('de-DE')}`,
marginL, fy
)
pdf.text(`Seite ${i} von ${total}`, pageW - marginR, fy, { align: 'right' })
}
pdf.save(`${doc.document_type}_v${doc.version}.pdf`)
}
// document_type values used in the DB / API
const TYPE_MAP = {
impressum: 'impressum',
@ -283,7 +216,13 @@ function LegalPage({ type }) {
<h1 style={{ margin: 0, color: 'var(--text1)' }}>{title}</h1>
{apiDoc && (
<button
onClick={() => generateLegalPdf(apiDoc)}
onClick={() => {
const dateStr = apiDoc.published_at
? new Date(apiDoc.published_at).toLocaleDateString('de-DE')
: new Date(apiDoc.updated_at || apiDoc.created_at).toLocaleDateString('de-DE')
const metaLine = `Version ${apiDoc.version} | Gueltig seit ${dateStr}`
generateLegalPdf(apiDoc, metaLine)
}}
className="btn btn-secondary"
style={{ fontSize: '0.82rem', padding: '4px 12px', flexShrink: 0 }}
>
@ -295,11 +234,11 @@ function LegalPage({ type }) {
{sections.map((section, i) => (
<div key={i} style={{ marginBottom: '1.75rem' }}>
<h2 style={{ fontSize: '1.05rem', marginBottom: '0.4rem', color: 'var(--text1)' }}>
{section.heading}
{section.heading
? `${legalSectionNumber(i)} ${section.heading}`
: legalSectionNumber(i)}
</h2>
<p style={{ color: isPlaceholder ? 'var(--text3)' : 'var(--text1)', fontStyle: isPlaceholder ? 'italic' : 'normal', margin: 0, whiteSpace: 'pre-wrap' }}>
{section.content}
</p>
<LegalDocumentBody content={section.content} muted={isPlaceholder} />
</div>
))}
</>

View File

@ -0,0 +1,391 @@
import { jsPDF } from 'jspdf'
import { lexer } from 'marked'
/** Fortlaufende Abschnittsnummer nur für die Ausgabe (nicht persistiert). */
export function legalSectionNumber(indexZeroBased) {
return `§${indexZeroBased + 1}`
}
const PDF_FONT = {
normal: ['helvetica', 'normal'],
bold: ['helvetica', 'bold'],
italic: ['helvetica', 'italic'],
bolditalic: ['helvetica', 'bolditalic'],
}
function setPdfFont(pdf, styleKey) {
const [name, variant] = PDF_FONT[styleKey] || PDF_FONT.normal
try {
pdf.setFont(name, variant)
} catch {
pdf.setFont('helvetica', styleKey === 'bolditalic' || styleKey === 'bold' ? 'bold' : 'normal')
}
}
/** Inline-Tokens → zusammengefügte Läufe mit Schriftstil */
function collectInlineRuns(tokens, base = { bold: false, italic: false }) {
const runs = []
const styleKey = (b) => {
if (b.bold && b.italic) return 'bolditalic'
if (b.bold) return 'bold'
if (b.italic) return 'italic'
return 'normal'
}
const flush = (text, b) => {
if (!text) return
runs.push({ style: styleKey(b), text })
}
const walk = (ts, b) => {
for (const t of ts || []) {
switch (t.type) {
case 'text':
case 'escape':
flush(t.text, b)
break
case 'strong':
walk(t.tokens || [{ type: 'text', text: t.text }], { ...b, bold: true })
break
case 'em':
walk(t.tokens || [{ type: 'text', text: t.text }], { ...b, italic: true })
break
case 'codespan':
flush(`${t.text}`, b)
break
case 'link': {
walk(t.tokens || [{ type: 'text', text: t.text }], b)
const href = t.href || ''
if (href) flush(` (${href})`, b)
break
}
case 'br':
flush('\n', b)
break
case 'del':
walk(t.tokens || [{ type: 'text', text: t.text }], b)
break
case 'image':
flush(`[${t.text || 'Bild'}]`, b)
break
default:
if (t.tokens) walk(t.tokens, b)
else if (t.text) flush(t.text, b)
}
}
}
walk(tokens, base)
const merged = []
for (const r of runs) {
const last = merged[merged.length - 1]
if (last && last.style === r.style) last.text += r.text
else merged.push({ ...r })
}
return merged
}
function splitLongWord(pdf, word, maxW) {
if (pdf.getTextWidth(word) <= maxW) return [word]
const out = []
let buf = ''
for (const ch of word) {
const tryBuf = buf + ch
if (pdf.getTextWidth(tryBuf) > maxW && buf) {
out.push(buf)
buf = ch
} else {
buf = tryBuf
}
}
if (buf) out.push(buf)
return out
}
/** Eine Zeile aus mehreren Läufen mit unterschiedlichen Schriften zeichnen */
function drawLine(pdf, pieces, x, y) {
let cx = x
for (const p of pieces) {
setPdfFont(pdf, p.style)
pdf.text(p.text, cx, y)
cx += pdf.getTextWidth(p.text)
}
}
function collectWordsFromRuns(pdf, runs, maxChunkW) {
const words = []
for (const run of runs) {
setPdfFont(pdf, run.style)
const parts = run.text.split(/(\s+)/)
for (const part of parts) {
if (!part) continue
if (part === '\n') {
words.push({ forceBreak: true })
continue
}
for (const c of splitLongWord(pdf, part, maxChunkW)) {
words.push({ ...run, text: c })
}
}
}
return words
}
/** Absatz aus Inline-Läufen umbrechen und zeichnen */
function runParagraphRuns(pdf, xStart, maxWidth, y, runs, lineStep, checkBreak, prefixRuns = []) {
const words = collectWordsFromRuns(pdf, [...prefixRuns, ...runs], maxWidth)
let linePieces = []
let lineW = 0
const flushLine = () => {
if (!linePieces.length) return
checkBreak(lineStep)
drawLine(pdf, linePieces, xStart, y)
y += lineStep
linePieces = []
lineW = 0
}
for (const w of words) {
if (w.forceBreak) {
flushLine()
continue
}
setPdfFont(pdf, w.style)
const tw = pdf.getTextWidth(w.text)
if (lineW + tw > maxWidth && linePieces.length) flushLine()
linePieces.push({ style: w.style, text: w.text })
lineW += tw
}
flushLine()
return y
}
/** Listenpunkt: Aufzählungszeichen in der ersten Zeile, hängender Einzug */
function renderListItemRuns(pdf, bullet, runs, x, y, maxW, lineStep, checkBreak) {
pdf.setFont('helvetica', 'normal')
const bulletW = pdf.getTextWidth(bullet)
const textX = x + bulletW
const textMaxW = Math.max(12, maxW - bulletW)
const words = collectWordsFromRuns(pdf, runs, textMaxW)
let linePieces = []
let lineW = 0
let firstLine = true
const flushLine = () => {
if (!linePieces.length) return
checkBreak(lineStep)
if (firstLine) {
pdf.setFont('helvetica', 'normal')
pdf.text(bullet, x, y)
firstLine = false
}
drawLine(pdf, linePieces, textX, y)
y += lineStep
linePieces = []
lineW = 0
}
for (const w of words) {
if (w.forceBreak) {
flushLine()
continue
}
setPdfFont(pdf, w.style)
const tw = pdf.getTextWidth(w.text)
if (lineW + tw > textMaxW && linePieces.length) flushLine()
linePieces.push({ style: w.style, text: w.text })
lineW += tw
}
flushLine()
return y
}
/**
* Fließtext mit **fett** / *kursiv* / Listen usw. ins PDF Zeilenumbruch per Wortgrenze.
* Gibt neue y-Position (mm) zurück.
*/
function renderMarkdownBlockToPdf(pdf, markdown, x, y, maxW, lineStep, checkBreak, gapAfterBlock = 2) {
if (!markdown || !String(markdown).trim()) return y + gapAfterBlock
const tokens = lexer(String(markdown), { gfm: true, breaks: true })
for (const token of tokens) {
switch (token.type) {
case 'space':
break
case 'paragraph': {
const runs = collectInlineRuns(token.tokens || [])
y = runParagraphRuns(pdf, x, maxW, y, runs, lineStep, checkBreak)
y += gapAfterBlock
break
}
case 'heading': {
checkBreak(8)
pdf.setFont('helvetica', 'bold')
pdf.setFontSize(10.5)
const hruns = collectInlineRuns(token.tokens || [])
const htext = hruns.map(r => r.text).join('')
const lines = pdf.splitTextToSize(htext, maxW)
for (const line of lines) {
checkBreak(6)
pdf.text(line, x, y)
y += 5.5
}
pdf.setFontSize(10)
pdf.setFont('helvetica', 'normal')
y += gapAfterBlock
break
}
case 'blockquote': {
const bx = x + 4
const bw = Math.max(20, maxW - 4)
let quoteStartY = null
for (const inner of token.tokens || []) {
if (inner.type === 'paragraph') {
if (quoteStartY == null) quoteStartY = y
const runs = collectInlineRuns(inner.tokens || [])
y = runParagraphRuns(pdf, bx, bw, y, runs, lineStep, checkBreak)
}
}
if (quoteStartY != null) {
pdf.setDrawColor(180, 180, 180)
pdf.setLineWidth(0.3)
pdf.line(x, quoteStartY - 3.5, x, y - 0.5)
pdf.setDrawColor(0, 0, 0)
}
y += gapAfterBlock
break
}
case 'code': {
checkBreak(lineStep)
pdf.setFont('courier', 'normal')
pdf.setFontSize(9)
const lines = pdf.splitTextToSize(token.text, maxW)
for (const line of lines) {
checkBreak(4.5)
pdf.text(line, x, y)
y += 4.5
}
pdf.setFontSize(10)
pdf.setFont('helvetica', 'normal')
y += gapAfterBlock
break
}
case 'list': {
let n = typeof token.start === 'number' ? token.start : 1
for (const item of token.items || []) {
const bullet = token.ordered ? `${n}. ` : '• '
n += 1
for (const it of item.tokens || []) {
if (it.type === 'paragraph') {
const runs = collectInlineRuns(it.tokens || [])
y = renderListItemRuns(pdf, bullet, runs, x, y, maxW, lineStep, checkBreak)
}
}
}
y += gapAfterBlock
break
}
case 'hr':
checkBreak(4)
pdf.setDrawColor(200, 200, 200)
pdf.line(x, y, x + maxW, y)
y += 5
pdf.setDrawColor(0, 0, 0)
break
default:
break
}
}
return y
}
/**
* Rechtstext als PDF (Abschnitte mit §-Nummerierung, Markdown im Fließtext).
* @param {object} doc API-Dokument mit title, content_sections, version,
* @param {string} metaLine z. B. Version + Datum
*/
export function generateLegalPdf(doc, metaLine) {
const pdf = new jsPDF({ format: 'a4', unit: 'mm' })
const marginL = 22
const marginR = 22
const marginTop = 28
const pageW = 210
const contentW = pageW - marginL - marginR
const bottomLimit = 277
let y = marginTop
const checkBreak = (need) => {
if (y + need > bottomLimit) {
pdf.addPage()
y = marginTop
}
}
pdf.setFont('helvetica', 'bold')
pdf.setFontSize(20)
pdf.text(doc.title, marginL, y)
y += 10
pdf.setFont('helvetica', 'normal')
pdf.setFontSize(10)
pdf.setTextColor(90, 90, 90)
pdf.text(metaLine, marginL, y)
y += 3
pdf.setDrawColor(0, 0, 0)
pdf.setLineWidth(0.4)
pdf.line(marginL, y, pageW - marginR, y)
y += 8
pdf.setTextColor(0, 0, 0)
const sections = doc.content_sections || []
sections.forEach((section, si) => {
const secNum = legalSectionNumber(si)
const head = [section.heading || '']
.filter(Boolean)
.length
? `${secNum} ${section.heading || ''}`
: secNum
checkBreak(14)
pdf.setFont('helvetica', 'bold')
pdf.setFontSize(11)
const headLines = pdf.splitTextToSize(head, contentW)
for (const hl of headLines) {
checkBreak(6)
pdf.text(hl, marginL, y)
y += 6
}
if (section.content) {
pdf.setFont('helvetica', 'normal')
pdf.setFontSize(10)
y = renderMarkdownBlockToPdf(pdf, section.content, marginL, y, contentW, 5, checkBreak, 4)
}
y += 3
})
const total = pdf.getNumberOfPages()
for (let i = 1; i <= total; i++) {
pdf.setPage(i)
pdf.setFont('helvetica', 'normal')
pdf.setFontSize(8)
pdf.setTextColor(150, 150, 150)
const fy = 289
pdf.text(
`Shinkan Jinkendo | Exportiert am ${new Date().toLocaleDateString('de-DE')}`,
marginL,
fy,
)
pdf.text(`Seite ${i} von ${total}`, pageW - marginR, fy, { align: 'right' })
pdf.setTextColor(0, 0, 0)
}
pdf.save(`${doc.document_type}_v${doc.version}.pdf`)
}