Add support for rotated appendices: implement 90° CCW image rotation for portrait pages, enhance table rendering logic, and update diagram handling directives.

This commit is contained in:
Sebastian Unterschütz
2026-05-17 23:41:45 +02:00
parent 427372b82b
commit d6b3854681
9 changed files with 318 additions and 22 deletions
+149 -13
View File
@@ -3,6 +3,7 @@ package main
import (
"fmt"
"log"
"sort"
"strconv"
"strings"
@@ -30,8 +31,19 @@ func (r *IHKRenderer) RenderHeader(level int, title string) {
// 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)
// 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)
@@ -229,9 +241,93 @@ func (r *IHKRenderer) wrapCellSpans(spans []InlineSpan, maxWidth float64, forceB
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, colW, lineHt float64, bold bool) tableRowData {
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++ {
@@ -239,7 +335,11 @@ func (r *IHKRenderer) prepareRow(rawCells [][]InlineSpan, numCols int, colW, lin
if j < len(rawCells) {
spans = rawCells[j]
}
lines := r.wrapCellSpans(spans, colW-2, bold)
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)
@@ -254,7 +354,15 @@ func (r *IHKRenderer) prepareRow(rawCells [][]InlineSpan, numCols int, colW, lin
// 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) {
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()
@@ -264,14 +372,25 @@ func (r *IHKRenderer) drawRow(row tableRowData, numCols int, colW, lineHt float6
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(lm+float64(j)*colW, startY, colW, row.height, "FD")
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 := lm + float64(j)*colW
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 {
@@ -282,7 +401,7 @@ func (r *IHKRenderer) drawRow(row tableRowData, numCols int, colW, lineHt float6
}
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, "")
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)
@@ -330,17 +449,17 @@ func (r *IHKRenderer) renderTableBody(data [][][]InlineSpan, label string) {
}
uw := r.usableWidth()
colW := uw / float64(numCols)
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, colW, lineHt, true)
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, colW, lineHt, false)
bodyRows[i-1] = r.prepareRow(data[i], numCols, colWidths, lineHt, false)
}
renderHeader := func(continued bool) {
@@ -352,7 +471,24 @@ func (r *IHKRenderer) renderTableBody(data [][][]InlineSpan, label string) {
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)
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)
@@ -362,7 +498,7 @@ func (r *IHKRenderer) renderTableBody(data [][][]InlineSpan, label string) {
r.pdf.AddPage()
renderHeader(true)
}
r.drawRow(row, numCols, colW, lineHt, false)
r.drawRow(row, numCols, colWidths, lineHt, false)
}
r.pdf.Ln(dinSpaceAfterParagraph)
}