package main import ( "fmt" "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() { // 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, dinLineHtBody, 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, dinLineHtBody, 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(dinLineHtBody, r.tr(span.Text)) } r.pdf.Ln(dinLineHtBody) } } // 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. 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) 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) } renderHeaderRow(false) r.pdf.SetFont("Helvetica", "", dinFontCaption) r.pdf.SetFillColor(255, 255, 255) _, pageH := r.pdf.GetPageSize() _, _, _, bm := r.pdf.GetMargins() rowH := dinLineHtCaption + 2 for i := 1; i < len(data); i++ { if r.pdf.GetY()+rowH > pageH-bm { r.pdf.AddPage() renderHeaderRow(true) r.pdf.SetFont("Helvetica", "", dinFontCaption) } for _, col := range data[i] { r.pdf.CellFormat(colW, rowH, r.tr(col), "1", 0, "L", false, 0, "") } r.pdf.Ln(-1) } 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 { 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() { … } │ // └──────┴──────────────────────────────────┘ // // 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 { 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) 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() } // 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. r.pdf.SetFillColor(255, 255, 255) r.pdf.Ln(dinSpaceAfterParagraph) } // 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 "" } }