350 lines
9.9 KiB
Go
350 lines
9.9 KiB
Go
package main
|
||
|
||
import (
|
||
"fmt"
|
||
"strconv"
|
||
"strings"
|
||
|
||
"github.com/go-pdf/fpdf"
|
||
)
|
||
|
||
// InlineSpan represents a run of text with consistent inline formatting.
|
||
// Used to pass parsed inline Markdown nodes from the parser to the renderer.
|
||
type InlineSpan struct {
|
||
Text string
|
||
Bold bool
|
||
Italic bool
|
||
Code bool // inline code (`…`)
|
||
}
|
||
|
||
// RenderHeader renders a section heading per IHK/DIN 5008 rules:
|
||
// - Font: Helvetica Bold, 14pt
|
||
// - Before: 2 blank lines if not at the top of the page
|
||
// - After: 1 blank line
|
||
// - Level 1 headings always start on a new page
|
||
//
|
||
// The heading is also recorded in the TOC.
|
||
func (r *IHKRenderer) RenderHeader(level int, title string) {
|
||
if level == 1 {
|
||
// Major sections start on a new page per IHK convention.
|
||
r.pdf.AddPage()
|
||
} else if !r.isAtPageTop() {
|
||
// IHK: two blank lines before a heading that is not at the page top.
|
||
r.pdf.Ln(dinSpaceBeforeHeading)
|
||
}
|
||
|
||
r.RecordHeader(level, title)
|
||
|
||
r.pdf.SetFont("Helvetica", "B", dinFontHeading)
|
||
r.pdf.MultiCell(0, dinLineHtHeading, r.tr(title), "", "L", false)
|
||
// IHK: one blank line (Leerzeile) after every heading.
|
||
r.pdf.Ln(dinSpaceAfterHeading)
|
||
}
|
||
|
||
// RenderParagraphSpans renders a paragraph from pre-parsed inline spans.
|
||
//
|
||
// Plain paragraphs (no inline formatting) use MultiCell with Blocksatz (justified)
|
||
// alignment as required by IHK. Paragraphs with mixed bold/italic use Write()
|
||
// which produces left-aligned output — an inherent fpdf limitation for mixed fonts.
|
||
func (r *IHKRenderer) RenderParagraphSpans(spans []InlineSpan) {
|
||
if len(spans) == 0 {
|
||
return
|
||
}
|
||
|
||
hasFormatting := false
|
||
for _, s := range spans {
|
||
if s.Bold || s.Italic || s.Code {
|
||
hasFormatting = true
|
||
break
|
||
}
|
||
}
|
||
|
||
if !hasFormatting {
|
||
// All plain text: collect and render justified (Blocksatz).
|
||
text := ""
|
||
for _, s := range spans {
|
||
text += s.Text
|
||
}
|
||
r.pdf.SetFont("Helvetica", "", dinFontBody)
|
||
r.pdf.MultiCell(0, dinLineHtBody, r.tr(text), "", "J", false)
|
||
} else {
|
||
// Mixed formatting: render span-by-span using Write().
|
||
for _, span := range spans {
|
||
style := fontStyle(span.Bold, span.Italic)
|
||
if span.Code {
|
||
r.pdf.SetFont("Courier", style, dinFontBody)
|
||
} else {
|
||
r.pdf.SetFont("Helvetica", style, dinFontBody)
|
||
}
|
||
r.pdf.Write(dinLineHtBody, r.tr(span.Text))
|
||
}
|
||
r.pdf.Ln(dinLineHtBody)
|
||
}
|
||
r.pdf.Ln(dinSpaceAfterParagraph)
|
||
}
|
||
|
||
// RenderListItem renders a single list item with a bullet prefix.
|
||
// ordered: true → bullet "•"; false → numbered with index.
|
||
// indent: nesting depth (0 = top level).
|
||
func (r *IHKRenderer) RenderListItem(spans []InlineSpan, ordered bool, index, indent int) {
|
||
r.pdf.SetFont("Helvetica", "", dinFontBody)
|
||
|
||
lm, _, _, _ := r.pdf.GetMargins()
|
||
|
||
// 3 mm base indent + 5 mm per nesting level (reduced from previous 8 mm per level)
|
||
indentMM := 3.0 + float64(indent)*5.0
|
||
|
||
prefix := "• "
|
||
if ordered {
|
||
prefix = strconv.Itoa(index) + ". "
|
||
}
|
||
prefixW := r.pdf.GetStringWidth(prefix) + 1.0
|
||
|
||
// textX is where the item text starts; wrapped lines must align here too.
|
||
textX := lm + indentMM + prefixW
|
||
textW := r.usableWidth() - indentMM - prefixW
|
||
|
||
// Temporarily move the left margin to textX so that MultiCell wraps
|
||
// continuation lines flush with the text, not back to the page margin.
|
||
r.pdf.SetLeftMargin(textX)
|
||
defer r.pdf.SetLeftMargin(lm)
|
||
|
||
// Render bullet/number; CellFormat advances X to textX automatically.
|
||
r.pdf.SetX(lm + indentMM)
|
||
r.pdf.CellFormat(prefixW, dinLineHtBody, r.tr(prefix), "", 0, "L", false, 0, "")
|
||
|
||
hasFormatting := false
|
||
for _, s := range spans {
|
||
if s.Bold || s.Italic || s.Code {
|
||
hasFormatting = true
|
||
break
|
||
}
|
||
}
|
||
|
||
if !hasFormatting {
|
||
text := ""
|
||
for _, s := range spans {
|
||
text += s.Text
|
||
}
|
||
r.pdf.MultiCell(textW, dinLineHtBody, r.tr(text), "", "L", false)
|
||
} else {
|
||
for _, span := range spans {
|
||
style := fontStyle(span.Bold, span.Italic)
|
||
if span.Code {
|
||
r.pdf.SetFont("Courier", style, dinFontBody)
|
||
} else {
|
||
r.pdf.SetFont("Helvetica", style, dinFontBody)
|
||
}
|
||
r.pdf.Write(dinLineHtBody, r.tr(span.Text))
|
||
}
|
||
r.pdf.Ln(dinLineHtBody)
|
||
}
|
||
}
|
||
|
||
// RenderTable renders a data table with a header row (grey background) and
|
||
// a auto-repeat header on page breaks. Each table is numbered and recorded
|
||
// in the Tabellenverzeichnis per IHK section 2.4.
|
||
//
|
||
// data[0] is the header row; data[1:] are body rows.
|
||
func (r *IHKRenderer) RenderTable(data [][]string, caption string) {
|
||
if len(data) == 0 {
|
||
return
|
||
}
|
||
|
||
r.tableCount++
|
||
label := fmt.Sprintf("Tab. %d", r.tableCount)
|
||
if caption != "" {
|
||
label += ": " + caption
|
||
}
|
||
r.RecordTable(label)
|
||
|
||
header := data[0]
|
||
numCols := len(header)
|
||
uw := r.usableWidth()
|
||
colW := uw / float64(numCols)
|
||
|
||
// renderHeaderRow draws the (grey) header row, optionally with a "(Fortsetzung)" suffix.
|
||
renderHeaderRow := func(continued bool) {
|
||
title := label
|
||
if continued {
|
||
title += " (Fortsetzung)"
|
||
}
|
||
r.pdf.SetFont("Helvetica", "B", dinFontCaption)
|
||
r.pdf.CellFormat(0, dinLineHtCaption+2, r.tr(title), "", 1, "L", false, 0, "")
|
||
|
||
r.pdf.SetFillColor(230, 230, 230)
|
||
r.pdf.SetFont("Helvetica", "B", dinFontCaption)
|
||
for _, col := range header {
|
||
r.pdf.CellFormat(colW, dinLineHtCaption+2, r.tr(col), "1", 0, "C", true, 0, "")
|
||
}
|
||
r.pdf.Ln(-1)
|
||
}
|
||
|
||
renderHeaderRow(false)
|
||
|
||
r.pdf.SetFont("Helvetica", "", dinFontCaption)
|
||
r.pdf.SetFillColor(255, 255, 255)
|
||
|
||
_, pageH := r.pdf.GetPageSize()
|
||
_, _, _, bm := r.pdf.GetMargins()
|
||
rowH := dinLineHtCaption + 2
|
||
|
||
for i := 1; i < len(data); i++ {
|
||
if r.pdf.GetY()+rowH > pageH-bm {
|
||
r.pdf.AddPage()
|
||
renderHeaderRow(true)
|
||
r.pdf.SetFont("Helvetica", "", dinFontCaption)
|
||
}
|
||
for _, col := range data[i] {
|
||
r.pdf.CellFormat(colW, rowH, r.tr(col), "1", 0, "L", false, 0, "")
|
||
}
|
||
r.pdf.Ln(-1)
|
||
}
|
||
r.pdf.Ln(dinSpaceAfterParagraph)
|
||
}
|
||
|
||
// RenderImage embeds an image with a caption below it.
|
||
// The image is scaled to fit the printable width and the remaining page height.
|
||
// If the image does not fit on the current page, a new page is started.
|
||
// The caption is recorded in the Abbildungsverzeichnis.
|
||
func (r *IHKRenderer) RenderImage(path string, caption string) {
|
||
r.pdf.Ln(dinSpaceAfterParagraph)
|
||
|
||
info := r.ensureImageRegistered(path)
|
||
if info == nil {
|
||
r.pdf.SetFont("Helvetica", "I", dinFontCaption)
|
||
r.pdf.CellFormat(0, dinLineHtCaption, r.tr("[Bild nicht gefunden: "+path+"]"),
|
||
"1", 1, "C", false, 0, "")
|
||
return
|
||
}
|
||
|
||
imgW := info.Width() * ptToMM
|
||
imgH := info.Height() * ptToMM
|
||
|
||
uw := r.usableWidth()
|
||
captionH := dinLineHtCaption + 4
|
||
|
||
// Scale to available width
|
||
displayW := uw
|
||
displayH := imgH * (displayW / imgW)
|
||
|
||
_, pageH := r.pdf.GetPageSize()
|
||
_, _, _, bm := r.pdf.GetMargins()
|
||
availH := pageH - r.pdf.GetY() - bm - captionH
|
||
|
||
if displayH > availH {
|
||
if availH < 20 {
|
||
// Almost no space left — start a new page
|
||
r.pdf.AddPage()
|
||
availH = pageH - r.pdf.GetY() - bm - captionH
|
||
}
|
||
// Scale down to fit height
|
||
displayH = availH
|
||
displayW = imgW * (displayH / imgH)
|
||
if displayW > uw {
|
||
displayW = uw
|
||
displayH = imgH * (displayW / imgW)
|
||
}
|
||
}
|
||
|
||
lm, _, _, _ := r.pdf.GetMargins()
|
||
posX := lm + (uw-displayW)/2
|
||
currentY := r.pdf.GetY()
|
||
|
||
r.pdf.ImageOptions(path, posX, currentY, displayW, displayH, false,
|
||
fpdf.ImageOptions{ReadDpi: true}, 0, "")
|
||
r.pdf.SetY(currentY + displayH + 2)
|
||
|
||
// Figure caption and TOC registration
|
||
r.figureCount++
|
||
label := fmt.Sprintf("Abb. %d", r.figureCount)
|
||
if caption != "" {
|
||
label += ": " + caption
|
||
}
|
||
r.RecordFigure(label)
|
||
|
||
r.pdf.SetFont("Helvetica", "I", dinFontCaption)
|
||
r.pdf.CellFormat(0, captionH, r.tr(label), "", 1, "C", false, 0, "")
|
||
r.pdf.Ln(dinSpaceAfterParagraph)
|
||
}
|
||
|
||
// Code block layout constants.
|
||
const (
|
||
codeFont = 9.0 // pt — Courier, smaller than body text
|
||
codeLineHt = 4.5 // mm ≈ 9 pt × 1.3 line spacing
|
||
codeGutterW = 11.0 // mm — column reserved for line numbers
|
||
)
|
||
|
||
// RenderCodeBlock renders a fenced code block with a line-number gutter.
|
||
//
|
||
// Layout:
|
||
//
|
||
// ┌──────┬──────────────────────────────────┐
|
||
// │ 1 │ package main │
|
||
// │ 2 │ │
|
||
// │ 3 │ func main() { … } │
|
||
// └──────┴──────────────────────────────────┘
|
||
//
|
||
// The language label is shown in small italic text above the block.
|
||
// Lines that exceed the printable width are clipped — code should be
|
||
// formatted to reasonable lengths before conversion.
|
||
func (r *IHKRenderer) RenderCodeBlock(lang, code string) {
|
||
lines := strings.Split(strings.TrimRight(code, "\n"), "\n")
|
||
if len(lines) == 0 {
|
||
return
|
||
}
|
||
|
||
r.pdf.Ln(dinSpaceAfterParagraph)
|
||
|
||
// Language label — top-right, italic, grey
|
||
if lang != "" {
|
||
r.pdf.SetFont("Helvetica", "I", dinFontCaption)
|
||
r.pdf.SetTextColor(100, 100, 100)
|
||
r.pdf.CellFormat(0, dinLineHtCaption, r.tr(lang), "", 1, "R", false, 0, "")
|
||
r.pdf.SetTextColor(0, 0, 0)
|
||
}
|
||
|
||
lm, _, _, bm := r.pdf.GetMargins()
|
||
_, pageH := r.pdf.GetPageSize()
|
||
uw := r.usableWidth()
|
||
codeW := uw - codeGutterW
|
||
|
||
r.pdf.SetFont("Courier", "", codeFont)
|
||
|
||
for i, line := range lines {
|
||
// Start a new page if this line would fall below the bottom margin.
|
||
if r.pdf.GetY()+codeLineHt > pageH-bm {
|
||
r.pdf.AddPage()
|
||
}
|
||
|
||
// Gutter: darker grey, right-aligned line number.
|
||
r.pdf.SetFillColor(218, 218, 218)
|
||
r.pdf.SetX(lm)
|
||
r.pdf.CellFormat(codeGutterW, codeLineHt,
|
||
fmt.Sprintf("%4d ", i+1), "", 0, "R", true, 0, "")
|
||
|
||
// Code line: lighter grey, left-aligned, small leading space.
|
||
r.pdf.SetFillColor(246, 246, 246)
|
||
r.pdf.CellFormat(codeW, codeLineHt,
|
||
r.tr(" "+line), "", 1, "L", true, 0, "")
|
||
}
|
||
|
||
// Reset colours so subsequent content is unaffected.
|
||
r.pdf.SetFillColor(255, 255, 255)
|
||
r.pdf.Ln(dinSpaceAfterParagraph)
|
||
}
|
||
|
||
// fontStyle returns the fpdf font style string for a given bold/italic combination.
|
||
func fontStyle(bold, italic bool) string {
|
||
switch {
|
||
case bold && italic:
|
||
return "BI"
|
||
case bold:
|
||
return "B"
|
||
case italic:
|
||
return "I"
|
||
default:
|
||
return ""
|
||
}
|
||
}
|