Remove outdated toc_pages.txt, add new Go modules for IHK Chemnitz PDF rendering including diagrams, tables, and TOC functionality.

This commit is contained in:
Sebastian Unterschütz
2026-05-04 22:06:28 +02:00
parent e98f7efa52
commit 81745b5f48
23 changed files with 1532 additions and 809 deletions
+107 -485
View File
@@ -1,524 +1,136 @@
// Package main implements a Markdown-to-PDF converter that produces documents
// compliant with IHK Chemnitz project documentation guidelines (Verordnung 2020)
// and DIN 5008 formatting rules.
package main
import (
"fmt"
"github.com/go-pdf/fpdf"
"sort"
"strconv"
"strings"
"github.com/go-pdf/fpdf"
)
type NumberingType int
// IHK Chemnitz / DIN 5008 format constants.
// Source: "Hinweise zur Erarbeitung der Dokumentation über die Projektarbeit",
// IHK Chemnitz, Verordnung 2020.
const (
NumNone NumberingType = iota
NumRoman
NumArabic
// Page margins in mm
dinMarginLeft = 30.0 // 3.0 cm left margin
dinMarginRight = 40.0 // 4.0 cm right margin — Korrekturrand (examiner correction space)
dinMarginTop = 20.0 // 2.0 cm top margin
dinMarginBottom = 25.0 // 2.5 cm bottom margin
// Font sizes in pt — Arial/Helvetica, black
dinFontBody = 12.0 // body text
dinFontHeading = 14.0 // headings, bold
dinFontCaption = 10.0 // captions and footnotes
// Line heights in mm — 1½ line spacing: pt × 1.5 × (25.4 / 72)
dinLineHtBody = 6.35 // 12 pt × 1.5 = 18 pt = 6.35 mm
dinLineHtHeading = 7.41 // 14 pt × 1.5 = 21 pt = 7.41 mm
dinLineHtCaption = 5.29 // 10 pt × 1.5 = 15 pt = 5.29 mm
// Vertical spacing in mm
// IHK: two blank lines before a heading that is not at the top of the page;
// one blank line after every heading.
dinSpaceBeforeHeading = 2 * dinLineHtBody // ≈ 12.7 mm
dinSpaceAfterHeading = dinLineHtBody // ≈ 6.35 mm
dinSpaceAfterParagraph = 4.0 // between body paragraphs
)
type TOCItem struct {
Level int
Title string
PageStr string
}
type TableItem struct {
Title string
PageStr string
}
// Appendix holds the title and image path for one annex entry.
type Appendix struct {
Title string
Path string
}
// IHKRenderer is the central PDF generator for IHK Chemnitz project documentation.
// All DIN 5008 formatting rules are applied through its methods.
//
// Usage: create with NewIHKRenderer, call Render* methods in document order,
// then call Save. The two-pass rendering in main.go fills the TOC correctly.
type IHKRenderer struct {
pdf *fpdf.Fpdf
config Config
numType NumberingType
tocItems []TOCItem
tableItems []TableItem
sources []string
appendices []Appendix
pageOffset int
tableCount int
tr func(string) string
pdf *fpdf.Fpdf
config Config
numType NumberingType
tocItems []TOCItem
tableItems []TableItem
figureItems []FigureItem
sources []string
appendices []Appendix
// pageOffset is the PDF page number of the last Roman-numbered page.
// Main body page 1 is rendered as PDF page (pageOffset + 1).
pageOffset int
tableCount int
figureCount int
// tr translates UTF-8 strings to the Latin-1 encoding used by fpdf.
tr func(string) string
}
// NewIHKRenderer constructs a renderer pre-configured with DIN 5008 margins
// and an auto-footer that renders the correct page number style per section.
func NewIHKRenderer(config Config) *IHKRenderer {
pdf := fpdf.New("P", "mm", "A4", "")
// Margins in mm: Top 20, Bottom 25, Left 30, Right 40
pdf.SetMargins(30, 20, 40)
pdf.SetAutoPageBreak(true, 25)
pdf.SetMargins(dinMarginLeft, dinMarginTop, dinMarginRight)
pdf.SetAutoPageBreak(true, dinMarginBottom)
r := &IHKRenderer{
pdf: pdf,
config: config,
numType: NumNone,
tocItems: make([]TOCItem, 0),
tableItems: make([]TableItem, 0),
sources: make([]string, 0),
appendices: make([]Appendix, 0),
tr: pdf.UnicodeTranslatorFromDescriptor(""),
pdf: pdf,
config: config,
numType: NumNone,
tocItems: make([]TOCItem, 0),
tableItems: make([]TableItem, 0),
figureItems: make([]FigureItem, 0),
sources: make([]string, 0),
appendices: make([]Appendix, 0),
tr: pdf.UnicodeTranslatorFromDescriptor(""),
}
// IHK: page number centered at the bottom of every numbered page.
pdf.SetFooterFunc(func() {
if r.numType == NumNone {
return
}
pdf.SetY(-15)
pdf.SetFont("Helvetica", "", 10)
pdf.SetFont("Helvetica", "", dinFontCaption)
var pageStr string
if r.numType == NumRoman {
switch r.numType {
case NumRoman:
pageStr = toRoman(pdf.PageNo())
} else {
// Arabic numbering starts at 1 for the main body
displayPage := pdf.PageNo() - r.pageOffset
if displayPage <= 0 {
case NumArabic:
dp := pdf.PageNo() - r.pageOffset
if dp <= 0 {
return
}
pageStr = strconv.Itoa(displayPage)
pageStr = strconv.Itoa(dp)
}
pdf.CellFormat(0, 10, pageStr, "", 0, "C", false, 0, "")
})
return r
}
func (r *IHKRenderer) RenderTOC() {
r.numType = NumRoman
r.pdf.AddPage()
r.pdf.SetFont("Helvetica", "B", 14)
r.pdf.CellFormat(0, 20, r.tr("Inhaltsverzeichnis"), "", 1, "L", false, 0, "")
r.pdf.Ln(5)
// TOC Header as a table
totalWidth, _ := r.pdf.GetPageSize()
lm, _, rm, _ := r.pdf.GetMargins()
usableWidth := totalWidth - lm - rm
r.pdf.SetFont("Helvetica", "B", 12)
r.pdf.CellFormat(usableWidth-20, 10, r.tr("Inhalt"), "", 0, "L", false, 0, "")
r.pdf.CellFormat(20, 10, r.tr("Seite"), "", 1, "R", false, 0, "")
r.pdf.Ln(2)
r.pdf.SetFont("Helvetica", "", 12)
_, lineHt := r.pdf.GetFontSize()
lineHt *= 1.5
for _, item := range r.tocItems {
indent := float64((item.Level - 1) * 10)
r.pdf.SetX(lm + indent)
title := r.tr(item.Title)
pageStr := item.PageStr
titleWidth := r.pdf.GetStringWidth(title)
pageWidth := r.pdf.GetStringWidth(pageStr)
// Available width for dots
availableWidth := usableWidth - indent - titleWidth - pageWidth - 4
r.pdf.CellFormat(titleWidth+2, lineHt, title, "", 0, "L", false, 0, "")
if availableWidth > 0 {
dots := ""
dotWidth := r.pdf.GetStringWidth(".")
for i := 0; float64(i)*dotWidth < availableWidth; i++ {
dots += "."
}
r.pdf.CellFormat(availableWidth, lineHt, dots, "", 0, "L", false, 0, "")
}
r.pdf.CellFormat(pageWidth+2, lineHt, pageStr, "", 1, "R", false, 0, "")
}
}
func (r *IHKRenderer) RecordHeader(level int, title string) {
if r.numType != NumArabic {
return // Exclude front matter from TOC per IHK example
}
displayPage := r.pdf.PageNo() - r.pageOffset
pageStr := strconv.Itoa(displayPage)
if displayPage <= 0 {
pageStr = "1" // Fallback
}
r.tocItems = append(r.tocItems, TOCItem{
Level: level,
Title: title,
PageStr: pageStr,
})
}
func (r *IHKRenderer) RecordTable(title string) {
displayPage := r.pdf.PageNo() - r.pageOffset
pageStr := strconv.Itoa(displayPage)
if displayPage <= 0 {
pageStr = "1"
}
r.tableItems = append(r.tableItems, TableItem{
Title: title,
PageStr: pageStr,
})
}
func (r *IHKRenderer) RenderListOfTables() {
if len(r.tableItems) == 0 {
return
}
r.pdf.AddPage()
r.pdf.SetFont("Helvetica", "B", 14)
r.pdf.CellFormat(0, 20, r.tr("Tabellenverzeichnis"), "", 1, "L", false, 0, "")
r.pdf.Ln(5)
totalWidth, _ := r.pdf.GetPageSize()
lm, _, rm, _ := r.pdf.GetMargins()
usableWidth := totalWidth - lm - rm
r.pdf.SetFont("Helvetica", "B", 12)
r.pdf.CellFormat(usableWidth-20, 10, r.tr("Titel"), "", 0, "L", false, 0, "")
r.pdf.CellFormat(20, 10, r.tr("Seite"), "", 1, "R", false, 0, "")
r.pdf.Ln(2)
r.pdf.SetFont("Helvetica", "", 12)
_, lineHt := r.pdf.GetFontSize()
lineHt *= 1.5
for _, item := range r.tableItems {
title := r.tr(item.Title)
pageStr := item.PageStr
titleWidth := r.pdf.GetStringWidth(title)
pageWidth := r.pdf.GetStringWidth(pageStr)
availableWidth := usableWidth - titleWidth - pageWidth - 4
r.pdf.CellFormat(titleWidth+2, lineHt, title, "", 0, "L", false, 0, "")
if availableWidth > 0 {
dots := ""
dotWidth := r.pdf.GetStringWidth(".")
for i := 0; float64(i)*dotWidth < availableWidth; i++ {
dots += "."
}
r.pdf.CellFormat(availableWidth, lineHt, dots, "", 0, "L", false, 0, "")
}
r.pdf.CellFormat(pageWidth+2, lineHt, pageStr, "", 1, "R", false, 0, "")
}
}
func (r *IHKRenderer) RenderTitlePage() {
r.numType = NumNone
// Temporary symmetrical margins for title page to ensure visual centering
oldLM, oldTM, oldRM, _ := r.pdf.GetMargins()
r.pdf.SetMargins(30, 20, 30)
r.pdf.AddPage()
r.pdf.SetFont("Helvetica", "B", 16)
r.pdf.CellFormat(0, 20, r.tr("Abschlussprüfung zum ..."), "", 1, "C", false, 0, "")
r.pdf.CellFormat(0, 10, r.tr(r.config.Student.Profession), "", 1, "C", false, 0, "")
r.pdf.Ln(30)
r.pdf.SetFont("Helvetica", "", 12)
r.pdf.CellFormat(0, 10, r.tr("Projektarbeit von"), "", 1, "C", false, 0, "")
r.pdf.Ln(5)
r.pdf.SetFont("Helvetica", "B", 14)
r.pdf.CellFormat(0, 10, r.tr(r.config.Student.Name), "", 1, "C", false, 0, "")
r.pdf.Ln(30)
r.pdf.SetFont("Helvetica", "B", 16)
r.pdf.MultiCell(0, 10, r.tr(r.config.Project.Title), "", "C", false)
r.pdf.SetY(-80)
r.pdf.SetFont("Helvetica", "", 12)
r.pdf.CellFormat(60, 10, r.tr("Prüfungsperiode:"), "", 0, "L", false, 0, "")
r.pdf.CellFormat(0, 10, r.tr(r.config.Project.Period), "", 1, "L", false, 0, "")
r.pdf.CellFormat(60, 10, r.tr("Ausbildungsbetrieb:"), "", 0, "L", false, 0, "")
r.pdf.CellFormat(0, 10, r.tr(r.config.Student.Company), "", 1, "L", false, 0, "")
r.pdf.CellFormat(60, 10, r.tr("Projektbetreuer:"), "", 0, "L", false, 0, "")
r.pdf.CellFormat(0, 10, r.tr(r.config.Student.Supervisor), "", 1, "L", false, 0, "")
// Restore margins for the rest of the document
r.pdf.SetMargins(oldLM, oldTM, oldRM)
}
func (r *IHKRenderer) RenderDeclarationPage() {
r.numType = NumNone
r.pdf.AddPage()
r.pdf.SetFont("Helvetica", "B", 14)
r.pdf.CellFormat(0, 20, r.tr("Erklärung"), "", 1, "C", false, 0, "")
r.pdf.Ln(10)
r.pdf.SetFont("Helvetica", "", 12)
text := fmt.Sprintf("Ich versichere durch meine Unterschrift, dass ich diese Projektarbeit mit dem Thema „%s“ selbstständig, ohne fremde Hilfe angefertigt, alle Stellen, die ich wörtlich oder annähernd wörtlich aus Veröffentlichungen entnommen, als solche kenntlich gemacht und mich auch keiner anderen als der angegebenen Literatur oder sonstiger Hilfsmittel bedient habe. Die Projektarbeit hat in dieser oder ähnlicher Form weder der Industrie- und Handelskammer Chemnitz noch einer anderen Prüfungsinstitution vorgelegen.", r.config.Project.Title)
r.pdf.MultiCell(0, 7.5, r.tr(text), "", "J", false)
r.pdf.Ln(20)
r.pdf.CellFormat(0, 10, r.tr("Ort, Datum, Unterschrift (mit Vor- und Nachnamen)"), "", 1, "L", false, 0, "")
}
func (r *IHKRenderer) RenderHeader(level int, title string) {
if level == 1 {
r.pdf.AddPage() // New page for major sections? Maybe optional.
}
r.RecordHeader(level, title)
r.pdf.SetFont("Helvetica", "B", 14)
r.pdf.Ln(5)
r.pdf.CellFormat(0, 10, r.tr(title), "", 1, "L", false, 0, "")
r.pdf.Ln(2)
}
func (r *IHKRenderer) RenderParagraph(text string) {
r.pdf.SetFont("Helvetica", "", 12)
// Line height for 12pt is 12pt * 1.5 = 18pt.
// 18pt is approx 6.35mm.
r.pdf.MultiCell(0, 7.5, r.tr(text), "", "J", false)
r.pdf.Ln(4)
}
func (r *IHKRenderer) RenderListItem(text string, bullet bool, index int) {
r.pdf.SetFont("Helvetica", "", 12)
prefix := "• "
if !bullet {
prefix = strconv.Itoa(index) + ". "
}
currentX := r.pdf.GetX()
r.pdf.SetX(currentX + 10)
r.pdf.MultiCell(0, 7.5, r.tr(prefix+text), "", "J", false)
r.pdf.SetX(currentX)
}
func (r *IHKRenderer) RenderTable(data [][]string) {
if len(data) == 0 {
return
}
r.tableCount++
tableTitle := fmt.Sprintf("Tab. %d", r.tableCount)
r.RecordTable(tableTitle)
r.pdf.SetFont("Helvetica", "B", 10)
header := data[0]
// Calculate column widths
numCols := len(header)
totalWidth, _ := r.pdf.GetPageSize()
lm, _, rm, _ := r.pdf.GetMargins()
usableWidth := totalWidth - lm - rm
colWidth := usableWidth / float64(numCols)
// Function to render header with optional "(Fortsetzung)"
renderHeader := func(continued bool) {
r.pdf.SetFont("Helvetica", "B", 10)
title := tableTitle
if continued {
title += " (Fortsetzung)"
}
r.pdf.CellFormat(0, 10, r.tr(title), "", 1, "L", false, 0, "")
r.pdf.SetFillColor(230, 230, 230)
for _, col := range header {
r.pdf.CellFormat(colWidth, 10, r.tr(col), "1", 0, "C", true, 0, "")
}
r.pdf.Ln(-1)
}
renderHeader(false)
r.pdf.SetFont("Helvetica", "", 10)
r.pdf.SetFillColor(255, 255, 255)
for i := 1; i < len(data); i++ {
row := data[i]
// Check for page break
_, pageH := r.pdf.GetPageSize()
_, _, _, bm := r.pdf.GetMargins()
if r.pdf.GetY()+10 > pageH-bm {
r.pdf.AddPage()
renderHeader(true)
r.pdf.SetFont("Helvetica", "", 10)
}
for _, col := range row {
r.pdf.CellFormat(colWidth, 10, r.tr(col), "1", 0, "L", false, 0, "")
}
r.pdf.Ln(-1)
}
r.pdf.Ln(5)
}
func (r *IHKRenderer) RenderImage(path string, caption string) {
r.pdf.Ln(5)
info := r.pdf.GetImageInfo(path)
if info == nil {
// Try to register it first
r.pdf.RegisterImageOptions(path, fpdf.ImageOptions{ReadDpi: true})
info = r.pdf.GetImageInfo(path)
}
if info == nil {
r.pdf.CellFormat(0, 10, r.tr("[Fehler beim Laden des Bildes: "+path+"]"), "1", 1, "C", false, 0, "")
return
}
// Calculate dimensions in mm
// 1 point = 0.352778 mm
imgW := info.Width() * 0.352778
imgH := info.Height() * 0.352778
maxWidth := 140.0
displayW := imgW
displayH := imgH
if displayW > maxWidth {
ratio := maxWidth / displayW
displayW = maxWidth
displayH = displayH * ratio
}
// Check if we need a new page
_, pageH := r.pdf.GetPageSize()
_, _, _, bottomMargin := r.pdf.GetMargins()
currentY := r.pdf.GetY()
if currentY+displayH+15 > pageH-bottomMargin {
r.pdf.AddPage()
currentY = r.pdf.GetY()
}
// Center horizontally
posX := 30.0 + (maxWidth-displayW)/2
r.pdf.ImageOptions(path, posX, currentY, displayW, displayH, false, fpdf.ImageOptions{ReadDpi: true}, 0, "")
// Move Y after the image
r.pdf.SetY(currentY + displayH + 2)
if caption != "" {
r.pdf.SetFont("Helvetica", "I", 10)
r.pdf.CellFormat(0, 10, r.tr(caption), "", 1, "C", false, 0, "")
}
r.pdf.Ln(5)
}
func (r *IHKRenderer) StartMainBody() {
r.numType = NumArabic
// The current page is the last Roman page
// So the next page will be display page 1
r.pageOffset = r.pdf.PageNo()
}
// StartFrontMatter activates Roman-numeral page numbering.
// Must be called before adding the first front-matter page (i.e., before RenderTOC).
// The title page (page 1) is unnumbered; the TOC appears as page II.
func (r *IHKRenderer) StartFrontMatter() {
r.numType = NumRoman
}
func (r *IHKRenderer) RenderBibliography() {
if len(r.sources) == 0 {
return
}
// RenderHeader(1, ...) already adds a page
r.RenderHeader(1, "Literaturverzeichnis")
r.pdf.Ln(5)
r.pdf.SetFont("Helvetica", "", 12)
// IHK Rule 2.8.1: Sort by author name
sort.Strings(r.sources)
for _, source := range r.sources {
r.pdf.MultiCell(0, 7.5, r.tr("- "+source), "", "J", false)
r.pdf.Ln(2)
}
}
func (r *IHKRenderer) RenderAppendices() {
if len(r.appendices) == 0 {
return
}
// RenderHeader(1, ...) already adds a page
r.RenderHeader(1, "Anhang")
r.pdf.Ln(5)
r.pdf.SetFont("Helvetica", "B", 12)
r.pdf.CellFormat(0, 10, r.tr("Anlagenverzeichnis"), "", 1, "L", false, 0, "")
r.pdf.SetFont("Helvetica", "", 12)
for i, app := range r.appendices {
r.pdf.CellFormat(0, 10, r.tr(fmt.Sprintf("Anlage %d: %s", i+1, app.Title)), "", 1, "L", false, 0, "")
}
for i, app := range r.appendices {
r.pdf.AddPage()
r.pdf.SetFont("Helvetica", "B", 12)
r.pdf.CellFormat(0, 10, r.tr(fmt.Sprintf("Anlage %d: %s", i+1, app.Title)), "", 1, "L", false, 0, "")
r.pdf.Ln(5)
info := r.pdf.GetImageInfo(app.Path)
if info == nil {
r.pdf.RegisterImageOptions(app.Path, fpdf.ImageOptions{ReadDpi: true})
info = r.pdf.GetImageInfo(app.Path)
}
if info != nil {
imgW := info.Width() * 0.352778
imgH := info.Height() * 0.352778
maxWidth := 140.0
// Scale to full width
displayW := maxWidth
ratio := displayW / imgW
displayH := imgH * ratio
// Check if it fits on page height, if not, scale down
_, pageH := r.pdf.GetPageSize()
_, _, _, bottomMargin := r.pdf.GetMargins()
availableH := pageH - r.pdf.GetY() - bottomMargin - 10
if displayH > availableH {
ratio := availableH / displayH
displayH = availableH
displayW = displayW * ratio
}
posX := 30.0 + (140.0-displayW)/2
r.pdf.ImageOptions(app.Path, posX, r.pdf.GetY(), displayW, displayH, false, fpdf.ImageOptions{ReadDpi: true}, 0, "")
} else {
r.pdf.CellFormat(0, 10, r.tr("[Bild konnte nicht geladen werden]"), "1", 1, "C", false, 0, "")
}
}
}
func (r *IHKRenderer) AddAppendix(titlePath string) {
parts := strings.Split(titlePath, "|")
if len(parts) < 2 {
return
}
title := strings.TrimSpace(parts[0])
path := strings.TrimSpace(parts[1])
r.appendices = append(r.appendices, Appendix{Title: title, Path: path})
// StartMainBody switches to Arabic page numbering and captures the current PDF
// page as the offset so that the first main-body page displays as "1".
func (r *IHKRenderer) StartMainBody() {
r.numType = NumArabic
r.pageOffset = r.pdf.PageNo()
}
// AddSource registers a bibliography entry. Whitespace is trimmed;
// duplicate entries are silently ignored.
func (r *IHKRenderer) AddSource(source string) {
// Normalize and store
source = strings.TrimSpace(source)
if source == "" {
return
}
// Prevent duplicates
for _, s := range r.sources {
if s == source {
return
@@ -527,23 +139,33 @@ func (r *IHKRenderer) AddSource(source string) {
r.sources = append(r.sources, source)
}
// AddAppendix registers an annex entry in "Title | /path/to/image" format.
func (r *IHKRenderer) AddAppendix(titlePath string) {
parts := strings.SplitN(titlePath, "|", 2)
if len(parts) < 2 {
return
}
r.appendices = append(r.appendices, Appendix{
Title: strings.TrimSpace(parts[0]),
Path: strings.TrimSpace(parts[1]),
})
}
// Save writes the completed PDF to the given file path.
func (r *IHKRenderer) Save(filename string) error {
return r.pdf.OutputFileAndClose(filename)
}
func toRoman(n int) string {
if n <= 0 {
return ""
}
values := []int{1000, 900, 500, 400, 100, 90, 50, 40, 10, 9, 5, 4, 1}
symbols := []string{"M", "CM", "D", "CD", "C", "XC", "L", "XL", "X", "IX", "V", "IV", "I"}
res := ""
for i := 0; i < len(values); i++ {
for n >= values[i] {
n -= values[i]
res += symbols[i]
}
}
return res
// usableWidth returns the printable line width on the current page in mm.
func (r *IHKRenderer) usableWidth() float64 {
w, _ := r.pdf.GetPageSize()
lm, _, rm, _ := r.pdf.GetMargins()
return w - lm - rm
}
// isAtPageTop returns true when the Y cursor sits at or within 1 mm of the
// top margin, meaning no body content has been placed on this page yet.
func (r *IHKRenderer) isAtPageTop() bool {
_, tm, _, _ := r.pdf.GetMargins()
return r.pdf.GetY() <= tm+1.0
}