Initial commit: added Markdown to IHK Chemnitz PDF converter with core structure and features, including YAML config, Goldmark parser, and PDF renderer.

This commit is contained in:
Sebastian Unterschütz
2026-04-09 23:09:45 +02:00
parent ca76fc4a8a
commit e98f7efa52
15 changed files with 1098 additions and 0 deletions

View File

@@ -0,0 +1,47 @@
# Markdown to IHK Chemnitz PDF Converter
Dieses Tool konvertiert Markdown-Dateien in professionelle Projektdokumentationen, die den formalen Anforderungen der **IHK Chemnitz** (IT-Berufe, Stand 2020/2026) entsprechen.
## Features
- **IHK-konforme Formatierung**: Automatische Einhaltung von Seitenrändern (30mm links, 40mm Korrekturrand rechts), Schriftarten und Zeilenabständen.
- **Paging in Tabellen**:
- **Tabellen-Pagination**: Automatisches Wiederholen der Kopfzeile auf Folgeseiten.
- **Fortsetzungs-Markierung**: Kennzeichnung von umgebrochenen Tabellen mit "(Fortsetzung)".
- **Tabellen-Fußzeile**: Seitennummerierung und Projektdaten in einer sauberen Tabellenstruktur am Seitenende.
- **Diagramme via Kroki**: Unterstützung für Mermaid, PlantUML und andere Formate direkt im Markdown.
- **Automatisierte Verzeichnisse**:
- Inhaltsverzeichnis mit korrekter Paginierung (römisch/arabisch).
- Literaturverzeichnis (sortiert).
- Anlagenverzeichnis.
- **Metadaten**: Einfache Konfiguration von Schüler- und Projektdaten via YAML-Frontmatter.
## Installation
Stellen Sie sicher, dass [Go](https://golang.org/) installiert ist.
```bash
git clone https://github.com/ihk-markdown-renderer
cd MarkdownToIHKChemnits
go mod tidy
```
## Benutzung
1. Erstellen Sie eine `report.md` mit YAML-Frontmatter (siehe Beispiel).
2. Führen Sie den Konverter aus:
```bash
go run . -i report.md -o projektarbeit.pdf
```
## Projektstruktur
- `main.go`: Einstiegspunkt und Orchestrierung.
- `markdown_parser.go`: AST-Parsing und Integration der Goldmark-Extensions.
- `pdf_renderer.go`: FPDF-Logik für IHK-Layout, Tabellen-Paging und Fußzeilen.
- `config.go`: Konfigurationsstrukturen.
## Lizenz
MIT

15
config.go Normal file
View File

@@ -0,0 +1,15 @@
package main
type Config struct {
Student struct {
Name string `yaml:"name"`
Profession string `yaml:"profession"`
Company string `yaml:"company"`
Supervisor string `yaml:"supervisor"`
} `yaml:"student"`
Project struct {
Title string `yaml:"title"`
Period string `yaml:"period"`
Subtitle string `yaml:"subtitle"`
} `yaml:"project"`
}

11
go.mod Normal file
View File

@@ -0,0 +1,11 @@
module MarkdownToIHKChemnits
go 1.26.1
require (
github.com/go-pdf/fpdf v0.9.0 // indirect
github.com/yuin/goldmark v1.8.1 // indirect
github.com/yuin/goldmark-meta v1.1.0 // indirect
gopkg.in/yaml.v2 v2.3.0 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
)

11
go.sum Normal file
View File

@@ -0,0 +1,11 @@
github.com/go-pdf/fpdf v0.9.0 h1:PPvSaUuo1iMi9KkaAn90NuKi+P4gwMedWPHhj8YlJQw=
github.com/go-pdf/fpdf v0.9.0/go.mod h1:oO8N111TkmKb9D7VvWGLvLJlaZUQVPM+6V42pp3iV4Y=
github.com/yuin/goldmark v1.8.1 h1:id2TeYXe5FpqwLco0Pso4cNM5Z6Okt4g7kDw9QBMhTA=
github.com/yuin/goldmark v1.8.1/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg=
github.com/yuin/goldmark-meta v1.1.0 h1:pWw+JLHGZe8Rk0EGsMVssiNb/AaPMHfSRszZeUeiOUc=
github.com/yuin/goldmark-meta v1.1.0/go.mod h1:U4spWENafuA7Zyg+Lj5RqK/MF+ovMYtBvXi1lBb2VP0=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v2 v2.3.0 h1:clyUAQHOM3G0M3f5vQj7LuJrETvjVot3Z5el9nffUtU=
gopkg.in/yaml.v2 v2.3.0/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=

BIN
img-000.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 52 KiB

BIN
img-001.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.7 KiB

BIN
img-002.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.6 KiB

BIN
img-003.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.7 KiB

61
main.go Normal file
View File

@@ -0,0 +1,61 @@
package main
import (
"flag"
"fmt"
"log"
)
func main() {
inputMd := flag.String("i", "report.md", "Input Markdown file")
outputPdf := flag.String("o", "projektarbeit.pdf", "Output PDF file")
flag.Parse()
config, doc, content, err := ParseMarkdown(*inputMd)
if err != nil {
log.Fatalf("Error parsing markdown: %v", err)
}
// Pass 1: Dummy render to collect TOC info
dummyRenderer := NewIHKRenderer(config)
dummyRenderer.RenderTitlePage()
dummyRenderer.RenderTOC() // This is the placeholder
dummyRenderer.pageOffset = dummyRenderer.pdf.PageNo()
RenderAST(doc, content, dummyRenderer)
dummyRenderer.RenderBibliography()
dummyRenderer.RenderListOfTables()
dummyRenderer.RenderAppendices()
// Pass 2: Real render
renderer := NewIHKRenderer(config)
renderer.tocItems = dummyRenderer.tocItems
renderer.tableItems = dummyRenderer.tableItems
renderer.RenderTitlePage()
renderer.RenderTOC()
// Main Content
err = RenderAST(doc, content, renderer)
if err != nil {
log.Fatalf("Error rendering PDF: %v", err)
}
// 4. Bibliography
renderer.RenderBibliography()
// List of Tables
renderer.RenderListOfTables()
// 5. Appendices
renderer.RenderAppendices()
// 6. Declaration of Authenticity
renderer.RenderDeclarationPage()
err = renderer.Save(*outputPdf)
if err != nil {
log.Fatalf("Error saving PDF: %v", err)
}
fmt.Printf("Successfully generated %s\n", *outputPdf)
}

222
markdown_parser.go Normal file
View File

@@ -0,0 +1,222 @@
package main
import (
"bytes"
"compress/zlib"
"crypto/sha256"
"encoding/base64"
"fmt"
"github.com/yuin/goldmark"
"github.com/yuin/goldmark-meta"
"github.com/yuin/goldmark/ast"
"github.com/yuin/goldmark/extension"
extast "github.com/yuin/goldmark/extension/ast"
"github.com/yuin/goldmark/parser"
"github.com/yuin/goldmark/text"
"gopkg.in/yaml.v3"
"io"
"net/http"
"os"
"path/filepath"
"strings"
)
func ParseMarkdown(mdPath string) (Config, ast.Node, []byte, error) {
content, err := os.ReadFile(mdPath)
if err != nil {
return Config{}, nil, nil, err
}
md := goldmark.New(
goldmark.WithExtensions(meta.Meta, extension.Table),
)
context := parser.NewContext()
doc := md.Parser().Parse(text.NewReader(content), parser.WithContext(context))
metaData := meta.Get(context)
// Convert metaData map to Config struct
var config Config
yamlData, _ := yaml.Marshal(metaData)
err = yaml.Unmarshal(yamlData, &config)
if err != nil {
return Config{}, nil, nil, fmt.Errorf("error parsing metadata: %v", err)
}
return config, doc, content, nil
}
type parserState struct {
nextCodeIsAppendix bool
appendixTitle string
}
func RenderAST(doc ast.Node, content []byte, r *IHKRenderer) error {
r.StartFrontMatter()
state := &parserState{}
return ast.Walk(doc, func(n ast.Node, entering bool) (ast.WalkStatus, error) {
if !entering {
return ast.WalkContinue, nil
}
switch node := n.(type) {
case *ast.Heading:
if node.Level == 1 && r.numType == NumRoman {
title := extractText(node, content)
if title != "Vorwort" && title != "Abkürzungsverzeichnis" {
r.StartMainBody()
}
}
title := extractText(node, content)
r.RenderHeader(node.Level, title)
return ast.WalkSkipChildren, nil
case *ast.Paragraph:
text := extractText(node, content)
lines := strings.Split(text, "\n")
isMeta := false
for _, line := range lines {
line = strings.TrimSpace(line)
if strings.HasPrefix(line, "@Quelle:") {
r.AddSource(strings.TrimPrefix(line, "@Quelle:"))
isMeta = true
} else if strings.HasPrefix(line, "@Anhang:") {
r.AddAppendix(strings.TrimPrefix(line, "@Anhang:"))
isMeta = true
} else if strings.HasPrefix(line, "@AnhangUML:") {
state.nextCodeIsAppendix = true
state.appendixTitle = strings.TrimSpace(strings.TrimPrefix(line, "@AnhangUML:"))
isMeta = true
}
}
if isMeta {
return ast.WalkSkipChildren, nil
}
r.RenderParagraph(text)
return ast.WalkSkipChildren, nil
case *ast.FencedCodeBlock:
lang := string(node.Language(content))
code := extractCode(node, content)
if lang == "mermaid" || lang == "plantuml" || lang == "puml" {
imgPath, err := RenderDiagramViaKroki(lang, code)
if err == nil {
if state.nextCodeIsAppendix {
r.AddAppendix(state.appendixTitle + " | " + imgPath)
state.nextCodeIsAppendix = false
} else {
r.RenderImage(imgPath, "Diagramm: "+lang)
}
return ast.WalkSkipChildren, nil
}
}
case *ast.Image:
imgPath := string(node.Destination)
title := string(node.Title)
r.RenderImage(imgPath, title)
return ast.WalkSkipChildren, nil
case *ast.Blockquote:
// Check if first paragraph starts with "Quelle:"
first := node.FirstChild()
if first != nil {
if para, ok := first.(*ast.Paragraph); ok {
pText := extractText(para, content)
if strings.HasPrefix(pText, "Quelle:") || strings.HasPrefix(pText, "Source:") {
sourceText := strings.TrimPrefix(pText, "Quelle:")
sourceText = strings.TrimPrefix(sourceText, "Source:")
r.AddSource(sourceText)
return ast.WalkSkipChildren, nil
}
}
}
case *ast.List:
// Items will be handled by ListItem
case *ast.ListItem:
text := extractText(node, content)
r.RenderListItem(text, true, 0) // Basic bullet point for now
return ast.WalkSkipChildren, nil
case *extast.Table:
var tableData [][]string
for row := node.FirstChild(); row != nil; row = row.NextSibling() {
var rowData []string
for cell := row.FirstChild(); cell != nil; cell = cell.NextSibling() {
rowData = append(rowData, extractText(cell, content))
}
tableData = append(tableData, rowData)
}
r.RenderTable(tableData)
return ast.WalkSkipChildren, nil
}
return ast.WalkContinue, nil
})
}
func extractText(n ast.Node, content []byte) string {
var textStr string
for child := n.FirstChild(); child != nil; child = child.NextSibling() {
if textNode, ok := child.(*ast.Text); ok {
textStr += string(textNode.Segment.Value(content))
if textNode.HardLineBreak() || textNode.SoftLineBreak() {
textStr += "\n"
}
} else {
textStr += extractText(child, content)
}
}
if textStr == "" {
// Fallback for simple nodes
return string(n.Text(content))
}
return textStr
}
func extractCode(n *ast.FencedCodeBlock, content []byte) string {
var buf bytes.Buffer
for i := 0; i < n.Lines().Len(); i++ {
line := n.Lines().At(i)
buf.Write(line.Value(content))
}
return buf.String()
}
func RenderDiagramViaKroki(lang string, code string) (string, error) {
if lang == "puml" {
lang = "plantuml"
}
// Kroki encoding: zlib + base64url
var b bytes.Buffer
w := zlib.NewWriter(&b)
w.Write([]byte(code))
w.Close()
encoded := base64.URLEncoding.EncodeToString(b.Bytes())
url := fmt.Sprintf("https://kroki.io/%s/png/%s", lang, encoded)
// Cache based on hash
hash := fmt.Sprintf("%x", sha256.Sum256([]byte(code)))
cachePath := filepath.Join(os.TempDir(), "ihk_cache_"+hash+".png")
if _, err := os.Stat(cachePath); err == nil {
return cachePath, nil
}
resp, err := http.Get(url)
if err != nil {
return "", err
}
defer resp.Body.Close()
if resp.StatusCode != 200 {
return "", fmt.Errorf("kroki error: %d", resp.StatusCode)
}
out, err := os.Create(cachePath)
if err != nil {
return "", err
}
defer out.Close()
_, err = io.Copy(out, resp.Body)
return cachePath, err
}

549
pdf_renderer.go Normal file
View File

@@ -0,0 +1,549 @@
package main
import (
"fmt"
"github.com/go-pdf/fpdf"
"sort"
"strconv"
"strings"
)
type NumberingType int
const (
NumNone NumberingType = iota
NumRoman
NumArabic
)
type TOCItem struct {
Level int
Title string
PageStr string
}
type TableItem struct {
Title string
PageStr string
}
type Appendix struct {
Title string
Path string
}
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
}
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)
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.SetFooterFunc(func() {
if r.numType == NumNone {
return
}
pdf.SetY(-15)
pdf.SetFont("Helvetica", "", 10)
var pageStr string
if r.numType == NumRoman {
pageStr = toRoman(pdf.PageNo())
} else {
// Arabic numbering starts at 1 for the main body
displayPage := pdf.PageNo() - r.pageOffset
if displayPage <= 0 {
return
}
pageStr = strconv.Itoa(displayPage)
}
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()
}
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})
}
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
}
}
r.sources = append(r.sources, source)
}
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
}

BIN
projektarbeit.pdf Normal file

Binary file not shown.

84
report.md Normal file
View File

@@ -0,0 +1,84 @@
---
student:
name: "Max Mustermann"
profession: "Fachinformatiker Fachrichtung Anwendungsentwicklung"
company: "Musterfirma GmbH"
supervisor: "Sabine Supervisor"
project:
title: "Entwicklung eines Markdown-zu-IHK-Konverters"
subtitle: "Projektdokumentation zur Abschlussprüfung"
period: "Frühjahr 2026"
---
# Vorwort
Dieses Projekt entstand im Rahmen der Abschlussprüfung...
# 1. Problemstellung
## 1.1 Ausgangslage
Aktuell müssen IHK-Dokumentationen mühsam in Word formatiert werden, was fehleranfällig ist und viel Zeit kostet. Besonders schwierig ist die Einhaltung der Formvorgaben für Umlaute wie Ä, Ö, Ü und das Eszett ß.
## 1.2 Zielsetzung
Ziel ist ein Go-Tool, das Markdown in PDF umwandelt und dabei alle formalen Anforderungen der IHK Chemnitz erfüllt. Es soll die Prüfungsvorbereitung erleichtern und die Qualität der Dokumente erhöhen.
# 2. Projektablauf
## 2.1 Planung
Die Planung umfasst die Analyse der IHK-Vorgaben und das Design der Software-Architektur.
### Architektur-Übersicht (Mermaid)
```mermaid
graph TD
A[Markdown] --> B(Go Parser)
B --> C{Metadata?}
C -->|Ja| D[Config]
C -->|Nein| E[Default]
D --> F[PDF Renderer]
E --> F
F --> G[IHK PDF]
```
### Klassen-Diagramm (PlantUML)
```puml
@startuml
class IHKRenderer {
+RenderTOC()
+RenderBibliography()
+RenderAppendices()
}
IHKRenderer o-- TOCItem
IHKRenderer o-- Appendix
@enduml
```
## 2.2 Realisierung
Die Realisierung erfolgt in Go unter Verwendung von `goldmark` und `fpdf`.
| Tool | Zweck |
|------|-------|
| Go | Programmiersprache |
| Goldmark | Markdown Parser |
| FPDF | PDF Renderer |
@Quelle: Goldmark Documentation, https://github.com/yuin/goldmark, 2024
> Quelle: Go-PDF/Fpdf Documentation, https://github.com/go-pdf/fpdf, 2025
# 3. Zusammenfassung
Das Tool ermöglicht eine effiziente Erstellung von Dokumentationen unter Einhaltung aller Formatvorgaben.
@Quelle: IHK Chemnitz, Hinweise zur Erarbeitung der Dokumentation, 2020
@Anhang: Architektur Diagramm | test.png
@Anhang: Datenbank Schema | test.png
@AnhangUML: Sequenzdiagramm Generierung
```puml
@startuml
User -> Generator: Markdown
Generator -> Kroki: Code
Kroki -> Generator: PNG
Generator -> PDF: Embed
@enduml
```

BIN
test.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 292 B

98
toc_pages.txt Normal file
View File

@@ -0,0 +1,98 @@
Hinweise zur Erarbeitung der Dokumentation über die Projektarbeit
für die Abschlussprüfung in IT-Berufen
1 VORBEMERKUNGEN
In einer fachübergreifenden Projektarbeit soll nachgewiesen werden,
eine komplexe Problemstellung der betrieblichen Praxis erfassen,
darstellen, beurteilen und lösen zu können. Der Rahmen der The-
menstellungen ergibt sich aus den Prüfungsanforderungen. Sie muss
die betriebliche Praxis des Prüfungsteilnehmers berücksichtigen. Bei
der Bearbeitung Ihrer Projektarbeit (PJA) übernehmen Sie die Rolle
eines Mitarbeiters im IT-Bereich, der nicht nur Lösungsvorschläge in
diesem Umfeld erarbeitet, sondern diese auch gegenüber einem
Fachgremium (das auch die Geschäftsleitung oder der Kunde sein
kann) zu vertreten hat (Präsentation/ Fachgespräch). Dabei sollen
Sie nicht nur Ihre neu hinzugewonnenen technischen und kaufmän-
nischen Kenntnisse anwenden, sondern auch durch Ihre Energie,
Kreativität und Ideen unter Beweis stellen, dass Sie über die erfor-
derliche Handlungskompetenz verfügen, um Ihr Team effektiver und
das gesamte Unternehmen wettbewerbsfähiger zu machen.
Für die Gestaltung der PJA gibt es inzwischen reichlich Informations-
schriften und Handreichungen. Aus den langjährigen Prüfungserfah-
rungen der Prüfungsausschüsse der IHK Chemnitz ergeben sich As-
pekte, aus denen sich einige Ratschläge ableiten.
Mit diesen Hinweisen will die IHK Chemnitz auf diese Belange ein-
gehen und damit zur allgemeinen Information der Prüfungsteilneh-
mer beitragen.
2 AUFBAU EINER PROJEKTARBEIT
2.1 BESTANDTEILE
Übersicht Eine PJA sollte sich aus nachfolgenden Elementen zusam-
mensetzen:
1 Titelblatt1
2 Inhaltsverzeichnis2
3 Ggf. Abkürzungsverzeichnis2
4 Vorwort/ Einleitung2
Textteil (Problemstellung, betriebliche/ kundengerechte
5
Dokumentation der Ergebnisse, Zusammenfassung inkl.
Zeitplanung)3
6 Literaturverzeichnis3
7 Ggf. Abbildungs-/ Tabellenverzeichnis3
8 Ggf. Anlagenverzeichnis3
9 Ggf. Glossar3
10 Ggf. Stichwortverzeichnis3
11 Erklärung4
Tab. 1: Komponenten einer Projektarbeit
2.2 TITELBLATT
Übersicht Das Titelblatt enthält alle für diesen Prüfungsteil erforderlichen Da-
ten.
V orl ag e
Abb. 1: Tit el bla tt der Proj ekt ar b eit
Abschlussprüfung zum
Projektarbeit von
Max Mustermann
Thema der Projektarbeit
Prüfungsperiode:
Ausbildungsbetrieb
(bzw. Praktikumsbetrieb):
Projektbetreuer
2.3 INHALTSVERZEICHNIS
Inhalt Das Inhaltsverzeichnis zeigt die Gliederung der PJA und muss ent-
sprechend der DIN 5008 durchnummeriert und mit Seitenangaben
versehen werden. Es sollte das Thema differenziert aufschlüsseln,
logisch aufgebaut, deutlich formuliert und übersichtlich dargestellt
werden.