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 "" } }