Files
MarkdownToIHKChemnits/pdf_content.go
T

463 lines
13 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package main
import (
"fmt"
"log"
"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, 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 [][]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)
if numCols == 0 {
return
}
uw := r.usableWidth()
colW := uw / float64(numCols)
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)
bodyRows := make([]tableRowData, len(data)-1)
for i := 1; i < len(data); i++ {
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()
renderHeader(true)
}
r.drawRow(row, numCols, colW, 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 ""
}
}