Add support for appendices: landscape diagrams, tables, and images; implement Kroki URL configurability; enhance directive parsing logic.

This commit is contained in:
Sebastian Unterschütz
2026-05-12 21:44:37 +02:00
parent 436cdcc516
commit 67f9d63f24
12 changed files with 1025 additions and 221 deletions
+169 -56
View File
@@ -2,6 +2,7 @@ package main
import (
"fmt"
"log"
"strconv"
"strings"
@@ -111,7 +112,7 @@ func (r *IHKRenderer) RenderListItem(spans []InlineSpan, ordered bool, index, in
// 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, "")
r.pdf.CellFormat(prefixW, dinLineHtList, r.tr(prefix), "", 0, "L", false, 0, "")
hasFormatting := false
for _, s := range spans {
@@ -126,7 +127,7 @@ func (r *IHKRenderer) RenderListItem(spans []InlineSpan, ordered bool, index, in
for _, s := range spans {
text += s.Text
}
r.pdf.MultiCell(textW, dinLineHtBody, r.tr(text), "", "L", false)
r.pdf.MultiCell(textW, dinLineHtList, r.tr(text), "", "L", false)
} else {
for _, span := range spans {
style := fontStyle(span.Bold, span.Italic)
@@ -135,70 +136,149 @@ func (r *IHKRenderer) RenderListItem(spans []InlineSpan, ordered bool, index, in
} else {
r.pdf.SetFont("Helvetica", style, dinFontBody)
}
r.pdf.Write(dinLineHtBody, r.tr(span.Text))
r.pdf.Write(dinLineHtList, r.tr(span.Text))
}
r.pdf.Ln(dinLineHtBody)
r.pdf.Ln(dinLineHtList)
}
}
// 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.
// tableRowData holds pre-computed line-wrapped content for one table row.
type tableRowData struct {
cells [][]string // wrapped lines per cell
height float64 // total row height in mm
}
// prepareRow measures how many lines each cell needs and returns a tableRowData
// with the wrapped content. Font must be set for measurement before calling.
func (r *IHKRenderer) prepareRow(rawCells []string, numCols int, colW, lineHt float64, bold bool) tableRowData {
style := ""
if bold {
style = "B"
}
r.pdf.SetFont("Helvetica", style, dinFontCaption)
cells := make([][]string, numCols)
maxLines := 0
for j := 0; j < numCols; j++ {
raw := ""
if j < len(rawCells) {
raw = rawCells[j]
}
split := r.pdf.SplitLines([]byte(r.tr(raw)), colW-2)
lines := make([]string, len(split))
for k, b := range split {
lines[k] = string(b)
}
if len(lines) == 0 {
lines = []string{""}
}
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
// regardless of how many lines they contain.
func (r *IHKRenderer) drawRow(row tableRowData, numCols int, colW, lineHt float64, isHeader bool) {
startY := r.pdf.GetY()
lm, _, _, _ := r.pdf.GetMargins()
style := ""
align := "L"
if isHeader {
style = "B"
align = "C"
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.
r.pdf.SetFont("Helvetica", style, dinFontCaption)
for j, cellLines := range row.cells {
x := lm + float64(j)*colW
for k, line := range cellLines {
r.pdf.SetXY(x+1, startY+float64(k)*lineHt)
r.pdf.CellFormat(colW-2, lineHt, line, "", 0, align, 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 [][]string, 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 [][]string, label string) {
if len(data) == 0 {
return
}
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)
if numCols == 0 {
return
}
renderHeaderRow(false)
r.pdf.SetFont("Helvetica", "", dinFontCaption)
r.pdf.SetFillColor(255, 255, 255)
uw := r.usableWidth()
colW := uw / float64(numCols)
lineHt := dinLineHtCaption + 2
_, pageH := r.pdf.GetPageSize()
_, _, _, bm := r.pdf.GetMargins()
rowH := dinLineHtCaption + 2
// 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++ {
if r.pdf.GetY()+rowH > pageH-bm {
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()
renderHeaderRow(true)
r.pdf.SetFont("Helvetica", "", dinFontCaption)
renderHeader(true)
}
for _, col := range data[i] {
r.pdf.CellFormat(colW, rowH, r.tr(col), "1", 0, "L", false, 0, "")
}
r.pdf.Ln(-1)
r.drawRow(row, numCols, colW, lineHt, false)
}
r.pdf.Ln(dinSpaceAfterParagraph)
}
@@ -212,6 +292,7 @@ func (r *IHKRenderer) RenderImage(path string, caption string) {
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, "")
@@ -283,11 +364,13 @@ const (
// │ 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.
// 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 {
@@ -311,22 +394,33 @@ func (r *IHKRenderer) RenderCodeBlock(lang, code string) {
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 {
// Start a new page if this line would fall below the bottom margin.
if r.pdf.GetY()+codeLineHt > pageH-bm {
r.pdf.AddPage()
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, "")
}
// 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.
@@ -334,6 +428,25 @@ func (r *IHKRenderer) RenderCodeBlock(lang, code string) {
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 {