Files

683 lines
20 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package main
import (
"fmt"
"log"
"sort"
"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() {
// Require enough space for: before-spacing + heading + after-spacing + 3 body
// lines. If the remaining page space is too tight, start a new page instead
// of leaving an orphaned heading at the bottom.
const minBodyLines = 3
needed := dinSpaceBeforeHeading + dinLineHtHeading + dinSpaceAfterHeading + minBodyLines*dinLineHtBody
_, pageH := r.pdf.GetPageSize()
_, _, _, bm := r.pdf.GetMargins()
if r.pdf.GetY()+needed > pageH-bm {
r.pdf.AddPage()
} else {
// 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
}
// computeColWidths measures the natural content width of each column and
// returns per-column widths that sum to at most uw.
//
// When columns do not fit at natural width, narrow columns are protected:
// processing from smallest to largest, each column receives its natural width
// if a fair share of the remaining space allows it; only the widest columns
// absorb the compression. This prevents narrow ID/label columns from being
// crushed to illegibility by a single very wide content column.
func (r *IHKRenderer) computeColWidths(data [][][]InlineSpan, numCols int, uw float64) []float64 {
const padding = 4.0 // 2 mm left + 2 mm right per cell
natural := make([]float64, numCols)
for rowIdx, row := range data {
isHeader := rowIdx == 0
for j := 0; j < numCols; j++ {
if j >= len(row) {
continue
}
w := padding
for _, span := range row[j] {
if span.Code {
r.pdf.SetFont("Courier", "", dinFontCaption)
} else {
r.pdf.SetFont("Helvetica", fontStyle(span.Bold || isHeader, span.Italic), dinFontCaption)
}
w += r.pdf.GetStringWidth(r.tr(span.Text))
}
if w > natural[j] {
natural[j] = w
}
}
}
total := 0.0
for _, w := range natural {
total += w
}
if total <= uw {
// Scale all columns up proportionally so the table always fills the full
// usable width. Without this, tables with short content leave a blank gap
// on the right and borders don't align with the page layout.
scale := uw / total
for j := range natural {
natural[j] *= scale
}
return natural
}
// total > uw: scale down, but protect narrow columns from being crushed.
// Sort column indices from narrowest to widest natural width.
// Give each narrow column its full natural width while possible;
// the remaining (wider) columns share whatever space is left, proportionally.
order := make([]int, numCols)
for i := range order {
order[i] = i
}
sort.Slice(order, func(a, b int) bool { return natural[order[a]] < natural[order[b]] })
result := make([]float64, numCols)
remaining := uw
for pass, j := range order {
colsLeft := numCols - pass
fairShare := remaining / float64(colsLeft)
if natural[j] <= fairShare {
result[j] = natural[j]
remaining -= natural[j]
} else {
// This and all following (wider) columns share the remaining space
// proportionally to their natural widths.
naturalTail := 0.0
for _, k := range order[pass:] {
naturalTail += natural[k]
}
for _, k := range order[pass:] {
result[k] = natural[k] / naturalTail * remaining
}
break
}
}
return result
}
// 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, colWidths []float64, 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]
}
cw := 0.0
if j < len(colWidths) {
cw = colWidths[j]
}
lines := r.wrapCellSpans(spans, cw-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, colWidths []float64, lineHt float64, isHeader bool) {
// Disable fpdf's auto page break for the duration of the row so that
// CellFormat calls inside a row can never trigger a mid-row page break.
// (Rect already doesn't trigger page breaks, but CellFormat does — this
// makes the two consistent and prevents row-content from appearing on the
// wrong page relative to the row background.)
r.pdf.SetAutoPageBreak(false, 0)
defer r.pdf.SetAutoPageBreak(true, dinMarginBottom)
startY := r.pdf.GetY()
lm, _, _, _ := r.pdf.GetMargins()
if isHeader {
r.pdf.SetFillColor(230, 230, 230)
} else {
r.pdf.SetFillColor(255, 255, 255)
}
// Precompute cumulative x positions for each column.
xPos := make([]float64, numCols+1)
xPos[0] = lm
for j, cw := range colWidths {
xPos[j+1] = xPos[j] + cw
}
// Draw all cell backgrounds and borders first (uniform height via Rect).
for j := 0; j < numCols; j++ {
r.pdf.Rect(xPos[j], startY, colWidths[j], row.height, "FD")
}
// Render text on top, line by line per cell.
for j, cellLines := range row.cells {
x := xPos[j]
cw := 0.0
if j < len(colWidths) {
cw = colWidths[j]
}
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(cw-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()
colWidths := r.computeColWidths(data, numCols, uw)
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, colWidths, lineHt, true)
bodyRows := make([]tableRowData, len(data)-1)
for i := 1; i < len(data); i++ {
bodyRows[i-1] = r.prepareRow(data[i], numCols, colWidths, 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, colWidths, lineHt, true)
}
// Ensure label + header + at least the first body row fit on the current page
// before starting to render. Without this check, drawRow can start rendering
// the header near the bottom of the page and the background rect (drawn by
// Rect, which doesn't trigger auto page break) ends up on a different page
// from the cell text (drawn by CellFormat, which used to trigger page breaks).
labelH := 0.0
if label != "" {
labelH = lineHt
}
minH := labelH + headerRow.height
if len(bodyRows) > 0 {
minH += bodyRows[0].height
}
if r.pdf.GetY()+minH > pageH-bm {
r.pdf.AddPage()
}
renderHeader(false)
for _, row := range bodyRows {
if r.pdf.GetY()+row.height > pageH-bm {
r.pdf.AddPage()
renderHeader(true)
}
r.drawRow(row, numCols, colWidths, 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 ""
}
}