547 lines
16 KiB
Go
547 lines
16 KiB
Go
package main
|
||
|
||
import (
|
||
"fmt"
|
||
"log"
|
||
"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, dinLineHtList, 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, dinLineHtList, 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(dinLineHtList, r.tr(span.Text))
|
||
}
|
||
r.pdf.Ln(dinLineHtList)
|
||
}
|
||
}
|
||
|
||
// tableRowData holds pre-computed line-wrapped content for one table row.
|
||
type tableRowData struct {
|
||
cells [][][]InlineSpan // wrapped lines per cell; each line is a []InlineSpan
|
||
height float64 // total row height in mm
|
||
}
|
||
|
||
// wrapCellSpans splits a []InlineSpan into lines that fit within maxWidth mm.
|
||
// If forceBold is true every span is rendered bold (used for header rows).
|
||
func (r *IHKRenderer) wrapCellSpans(spans []InlineSpan, maxWidth float64, forceBold bool) [][]InlineSpan {
|
||
if len(spans) == 0 {
|
||
return [][]InlineSpan{{}}
|
||
}
|
||
|
||
type token struct {
|
||
text string
|
||
bold bool
|
||
italic bool
|
||
code bool
|
||
}
|
||
|
||
// Flatten spans into space-split tokens so we can wrap word-by-word.
|
||
var tokens []token
|
||
for _, sp := range spans {
|
||
b := sp.Bold || forceBold
|
||
parts := strings.Split(sp.Text, " ")
|
||
for i, part := range parts {
|
||
if i > 0 {
|
||
tokens = append(tokens, token{" ", false, false, false})
|
||
}
|
||
if part != "" {
|
||
tokens = append(tokens, token{part, b, sp.Italic, sp.Code})
|
||
}
|
||
}
|
||
}
|
||
|
||
// Measure each token with its correct font.
|
||
widths := make([]float64, len(tokens))
|
||
for i, tok := range tokens {
|
||
if tok.code {
|
||
r.pdf.SetFont("Courier", "", dinFontCaption)
|
||
} else {
|
||
r.pdf.SetFont("Helvetica", fontStyle(tok.bold, tok.italic), dinFontCaption)
|
||
}
|
||
widths[i] = r.pdf.GetStringWidth(tok.text)
|
||
}
|
||
|
||
// Greedily pack tokens into lines, merging consecutive tokens with equal style.
|
||
var lines [][]InlineSpan
|
||
var curLine []InlineSpan
|
||
curW := 0.0
|
||
|
||
pushToken := func(tok token) {
|
||
if len(curLine) > 0 {
|
||
last := &curLine[len(curLine)-1]
|
||
if last.Bold == tok.bold && last.Italic == tok.italic && last.Code == tok.code {
|
||
last.Text += tok.text
|
||
return
|
||
}
|
||
}
|
||
curLine = append(curLine, InlineSpan{Text: tok.text, Bold: tok.bold, Italic: tok.italic, Code: tok.code})
|
||
}
|
||
|
||
for i, tok := range tokens {
|
||
w := widths[i]
|
||
if tok.text == " " && curW == 0 {
|
||
continue // skip leading space on a fresh line
|
||
}
|
||
if curW+w > maxWidth && curW > 0 {
|
||
lines = append(lines, curLine)
|
||
curLine = nil
|
||
curW = 0
|
||
if tok.text == " " {
|
||
continue // discard the space that triggered the wrap
|
||
}
|
||
}
|
||
pushToken(tok)
|
||
curW += w
|
||
}
|
||
if len(curLine) > 0 {
|
||
lines = append(lines, curLine)
|
||
}
|
||
if len(lines) == 0 {
|
||
lines = [][]InlineSpan{{}}
|
||
}
|
||
return lines
|
||
}
|
||
|
||
// prepareRow wraps each cell's inline spans and returns a tableRowData.
|
||
// If bold is true, all cell text is forced bold (used for the header row).
|
||
func (r *IHKRenderer) prepareRow(rawCells [][]InlineSpan, numCols int, colW, lineHt float64, bold bool) tableRowData {
|
||
cells := make([][][]InlineSpan, numCols)
|
||
maxLines := 0
|
||
for j := 0; j < numCols; j++ {
|
||
var spans []InlineSpan
|
||
if j < len(rawCells) {
|
||
spans = rawCells[j]
|
||
}
|
||
lines := r.wrapCellSpans(spans, colW-2, bold)
|
||
cells[j] = lines
|
||
if len(lines) > maxLines {
|
||
maxLines = len(lines)
|
||
}
|
||
}
|
||
if maxLines == 0 {
|
||
maxLines = 1
|
||
}
|
||
return tableRowData{cells: cells, height: float64(maxLines) * lineHt}
|
||
}
|
||
|
||
// drawRow renders a pre-computed row at the current Y position.
|
||
// Cell borders are drawn as rectangles so all cells share a uniform height.
|
||
// Header cells are rendered bold and centred; body cells honour per-span formatting.
|
||
func (r *IHKRenderer) drawRow(row tableRowData, numCols int, colW, lineHt float64, isHeader bool) {
|
||
startY := r.pdf.GetY()
|
||
lm, _, _, _ := r.pdf.GetMargins()
|
||
|
||
if isHeader {
|
||
r.pdf.SetFillColor(230, 230, 230)
|
||
} else {
|
||
r.pdf.SetFillColor(255, 255, 255)
|
||
}
|
||
|
||
// Draw all cell backgrounds and borders first (uniform height via Rect).
|
||
for j := 0; j < numCols; j++ {
|
||
r.pdf.Rect(lm+float64(j)*colW, startY, colW, row.height, "FD")
|
||
}
|
||
|
||
// Render text on top, line by line per cell.
|
||
for j, cellLines := range row.cells {
|
||
x := lm + float64(j)*colW
|
||
for k, spanLine := range cellLines {
|
||
y := startY + float64(k)*lineHt
|
||
if isHeader {
|
||
// Header: plain bold text, centred.
|
||
plainText := ""
|
||
for _, s := range spanLine {
|
||
plainText += s.Text
|
||
}
|
||
r.pdf.SetFont("Helvetica", "B", dinFontCaption)
|
||
r.pdf.SetXY(x+1, y)
|
||
r.pdf.CellFormat(colW-2, lineHt, r.tr(plainText), "", 0, "C", false, 0, "")
|
||
} else {
|
||
// Body: render each span with its own font, left-aligned.
|
||
r.pdf.SetXY(x+1, y)
|
||
for _, span := range spanLine {
|
||
if span.Code {
|
||
r.pdf.SetFont("Courier", "", dinFontCaption)
|
||
} else {
|
||
r.pdf.SetFont("Helvetica", fontStyle(span.Bold, span.Italic), dinFontCaption)
|
||
}
|
||
sw := r.pdf.GetStringWidth(r.tr(span.Text))
|
||
r.pdf.CellFormat(sw, lineHt, r.tr(span.Text), "", 0, "L", false, 0, "")
|
||
}
|
||
}
|
||
}
|
||
}
|
||
r.pdf.SetY(startY + row.height)
|
||
}
|
||
|
||
// RenderTable numbers a table, records it in the Tabellenverzeichnis, and
|
||
// renders it with multi-line cell support and an auto-repeating header on
|
||
// page breaks. data[0] is the header row; data[1:] are body rows.
|
||
func (r *IHKRenderer) RenderTable(data [][][]InlineSpan, caption string) {
|
||
if len(data) == 0 {
|
||
return
|
||
}
|
||
r.tableCount++
|
||
label := fmt.Sprintf("Tab. %d", r.tableCount)
|
||
if caption != "" {
|
||
label += ": " + caption
|
||
}
|
||
r.RecordTable(label)
|
||
r.renderTableBody(data, label)
|
||
}
|
||
|
||
// renderTableBody renders table content without assigning a number or recording
|
||
// in the Tabellenverzeichnis. Pass an empty label to suppress the caption line.
|
||
func (r *IHKRenderer) renderTableBody(data [][][]InlineSpan, label string) {
|
||
if len(data) == 0 {
|
||
return
|
||
}
|
||
header := data[0]
|
||
numCols := len(header)
|
||
if numCols == 0 {
|
||
return
|
||
}
|
||
|
||
uw := r.usableWidth()
|
||
colW := uw / float64(numCols)
|
||
lineHt := dinLineHtCaption + 2
|
||
|
||
_, pageH := r.pdf.GetPageSize()
|
||
_, _, _, bm := r.pdf.GetMargins()
|
||
|
||
// Pre-compute all rows so we know heights before rendering.
|
||
headerRow := r.prepareRow(header, numCols, colW, lineHt, true)
|
||
bodyRows := make([]tableRowData, len(data)-1)
|
||
for i := 1; i < len(data); i++ {
|
||
bodyRows[i-1] = r.prepareRow(data[i], numCols, colW, lineHt, false)
|
||
}
|
||
|
||
renderHeader := func(continued bool) {
|
||
title := label
|
||
if continued && label != "" {
|
||
title += " (Fortsetzung)"
|
||
}
|
||
if title != "" {
|
||
r.pdf.SetFont("Helvetica", "B", dinFontCaption)
|
||
r.pdf.CellFormat(0, lineHt, r.tr(title), "", 1, "L", false, 0, "")
|
||
}
|
||
r.drawRow(headerRow, numCols, colW, lineHt, true)
|
||
}
|
||
|
||
renderHeader(false)
|
||
|
||
for _, row := range bodyRows {
|
||
if r.pdf.GetY()+row.height > pageH-bm {
|
||
r.pdf.AddPage()
|
||
renderHeader(true)
|
||
}
|
||
r.drawRow(row, numCols, colW, lineHt, false)
|
||
}
|
||
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 {
|
||
log.Printf("warning: image not found, rendering placeholder: %q", path)
|
||
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() { … } │
|
||
// │ 4 │ // long line wraps here … │
|
||
// │ │ … continuation │
|
||
// └──────┴──────────────────────────────────┘
|
||
//
|
||
// Lines that exceed the column width are wrapped. Continuation rows show
|
||
// an empty gutter so the source line number stays unambiguous.
|
||
// The language label is shown in small italic text above the block.
|
||
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)
|
||
|
||
// In Courier every glyph has the same advance width; one measurement suffices.
|
||
// Reserve 1 mm right padding so characters never touch the column edge.
|
||
charW := r.pdf.GetStringWidth("0")
|
||
maxChars := int((codeW-1)/charW) - 1 // -1 for the leading space we prepend
|
||
if maxChars < 1 {
|
||
maxChars = 1
|
||
}
|
||
|
||
for i, line := range lines {
|
||
chunks := wrapCodeLine(line, maxChars)
|
||
for ci, chunk := range chunks {
|
||
if r.pdf.GetY()+codeLineHt > pageH-bm {
|
||
r.pdf.AddPage()
|
||
}
|
||
// Gutter: darker grey; line number on the first chunk, blank on continuations.
|
||
r.pdf.SetFillColor(218, 218, 218)
|
||
r.pdf.SetX(lm)
|
||
if ci == 0 {
|
||
r.pdf.CellFormat(codeGutterW, codeLineHt,
|
||
fmt.Sprintf("%4d ", i+1), "", 0, "R", true, 0, "")
|
||
} else {
|
||
r.pdf.CellFormat(codeGutterW, codeLineHt, "", "", 0, "R", true, 0, "")
|
||
}
|
||
// Code column: lighter grey, left-aligned, small leading space.
|
||
r.pdf.SetFillColor(246, 246, 246)
|
||
r.pdf.CellFormat(codeW, codeLineHt, r.tr(" "+chunk), "", 1, "L", true, 0, "")
|
||
}
|
||
}
|
||
|
||
// Reset colours so subsequent content is unaffected.
|
||
r.pdf.SetFillColor(255, 255, 255)
|
||
r.pdf.Ln(dinSpaceAfterParagraph)
|
||
}
|
||
|
||
// wrapCodeLine splits a source code line into chunks of at most maxChars runes.
|
||
// A single-rune minimum prevents an infinite loop on extremely narrow columns.
|
||
func wrapCodeLine(line string, maxChars int) []string {
|
||
runes := []rune(line)
|
||
if len(runes) <= maxChars {
|
||
return []string{line}
|
||
}
|
||
var chunks []string
|
||
for len(runes) > 0 {
|
||
n := maxChars
|
||
if n > len(runes) {
|
||
n = len(runes)
|
||
}
|
||
chunks = append(chunks, string(runes[:n]))
|
||
runes = runes[n:]
|
||
}
|
||
return chunks
|
||
}
|
||
|
||
// 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 ""
|
||
}
|
||
}
|