Refactor table rendering: replace plain text with InlineSpan for rich text support, update row preparation, and improve PDF formatting logic.

This commit is contained in:
Sebastian Unterschütz
2026-05-14 21:40:08 +02:00
parent 2d3e544d4f
commit 427372b82b
5 changed files with 130 additions and 47 deletions
+116 -32
View File
@@ -144,34 +144,102 @@ func (r *IHKRenderer) RenderListItem(spans []InlineSpan, ordered bool, index, in
// 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
cells [][][]InlineSpan // wrapped lines per cell; each line is a []InlineSpan
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"
// 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{{}}
}
r.pdf.SetFont("Helvetica", style, dinFontCaption)
cells := make([][]string, numCols)
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++ {
raw := ""
var spans []InlineSpan
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{""}
spans = rawCells[j]
}
lines := r.wrapCellSpans(spans, colW-2, bold)
cells[j] = lines
if len(lines) > maxLines {
maxLines = len(lines)
@@ -184,17 +252,13 @@ func (r *IHKRenderer) prepareRow(rawCells []string, numCols int, colW, lineHt fl
}
// 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.
// 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()
style := ""
align := "L"
if isHeader {
style = "B"
align = "C"
r.pdf.SetFillColor(230, 230, 230)
} else {
r.pdf.SetFillColor(255, 255, 255)
@@ -206,12 +270,32 @@ func (r *IHKRenderer) drawRow(row tableRowData, numCols int, colW, lineHt float6
}
// 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, "")
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)
@@ -220,7 +304,7 @@ func (r *IHKRenderer) drawRow(row tableRowData, numCols int, colW, lineHt float6
// 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) {
func (r *IHKRenderer) RenderTable(data [][][]InlineSpan, caption string) {
if len(data) == 0 {
return
}
@@ -235,7 +319,7 @@ func (r *IHKRenderer) RenderTable(data [][]string, caption string) {
// 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) {
func (r *IHKRenderer) renderTableBody(data [][][]InlineSpan, label string) {
if len(data) == 0 {
return
}