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
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:
parent
04663e090a
commit
81b9e8f601
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
25
frontend/src/components/LegalDocumentBody.jsx
Normal file
25
frontend/src/components/LegalDocumentBody.jsx
Normal 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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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')
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
))}
|
||||
</>
|
||||
|
|
|
|||
391
frontend/src/utils/legalPdfExport.js
Normal file
391
frontend/src/utils/legalPdfExport.js
Normal 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`)
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user