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
+4
View File
@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="UTF-8"?>
<module version="4">
<component name="Go" enabled="true" />
</module>
+11
View File
@@ -0,0 +1,11 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="GoImports">
<option name="excludedPackages">
<array>
<option value="github.com/pkg/errors" />
<option value="golang.org/x/net/context" />
</array>
</option>
</component>
</project>
Generated
+6
View File
@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="VcsDirectoryMappings">
<mapping directory="$PROJECT_DIR$" vcs="Git" />
</component>
</project>
+117
View File
@@ -0,0 +1,117 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="AutoImportSettings">
<option name="autoReloadType" value="ALL" />
</component>
<component name="ChangeListManager">
<list default="true" id="c64f46d5-641a-468c-8fc1-94edec1f2deb" name="Changes" comment="Initial commit: added Markdown to IHK Chemnitz PDF converter with core structure and features, including YAML config, Goldmark parser, and PDF renderer.">
<change afterPath="$PROJECT_DIR$/it-berufe-handreichung-vo2020-data(1).pdf" afterDir="false" />
<change beforePath="$PROJECT_DIR$/README.md" beforeDir="false" afterPath="$PROJECT_DIR$/README.md" afterDir="false" />
<change beforePath="$PROJECT_DIR$/config.go" beforeDir="false" afterPath="$PROJECT_DIR$/config.go" afterDir="false" />
<change beforePath="$PROJECT_DIR$/img-000.png" beforeDir="false" />
<change beforePath="$PROJECT_DIR$/img-001.png" beforeDir="false" />
<change beforePath="$PROJECT_DIR$/img-002.png" beforeDir="false" />
<change beforePath="$PROJECT_DIR$/img-003.png" beforeDir="false" />
<change beforePath="$PROJECT_DIR$/main.go" beforeDir="false" afterPath="$PROJECT_DIR$/main.go" afterDir="false" />
<change beforePath="$PROJECT_DIR$/markdown_parser.go" beforeDir="false" afterPath="$PROJECT_DIR$/markdown_parser.go" afterDir="false" />
<change beforePath="$PROJECT_DIR$/pdf_renderer.go" beforeDir="false" afterPath="$PROJECT_DIR$/pdf_renderer.go" afterDir="false" />
<change beforePath="$PROJECT_DIR$/projektarbeit.pdf" beforeDir="false" afterPath="$PROJECT_DIR$/projektarbeit.pdf" afterDir="false" />
<change beforePath="$PROJECT_DIR$/report.md" beforeDir="false" afterPath="$PROJECT_DIR$/report.md" afterDir="false" />
<change beforePath="$PROJECT_DIR$/toc_pages.txt" beforeDir="false" />
</list>
<option name="SHOW_DIALOG" value="false" />
<option name="HIGHLIGHT_CONFLICTS" value="true" />
<option name="HIGHLIGHT_NON_ACTIVE_CHANGELIST" value="false" />
<option name="LAST_RESOLUTION" value="IGNORE" />
</component>
<component name="EmbeddingIndexingInfo">
<option name="cachedIndexableFilesCount" value="19" />
<option name="fileBasedEmbeddingIndicesEnabled" value="true" />
</component>
<component name="GOROOT" url="file:///usr/lib/go" />
<component name="Git.Settings">
<option name="RECENT_GIT_ROOT_PATH" value="$PROJECT_DIR$" />
</component>
<component name="ProjectColorInfo">{
&quot;associatedIndex&quot;: 5
}</component>
<component name="ProjectId" id="3BO2O12K0JCiteftC8DGGgmYYyZ" />
<component name="ProjectViewState">
<option name="hideEmptyMiddlePackages" value="true" />
<option name="showLibraryContents" value="true" />
</component>
<component name="PropertiesComponent">{
&quot;keyToString&quot;: {
&quot;ModuleVcsDetector.initialDetectionPerformed&quot;: &quot;true&quot;,
&quot;RunOnceActivity.GoLinterPluginOnboardingV2&quot;: &quot;true&quot;,
&quot;RunOnceActivity.GoLinterPluginStorageMigration&quot;: &quot;true&quot;,
&quot;RunOnceActivity.ShowReadmeOnStart&quot;: &quot;true&quot;,
&quot;RunOnceActivity.TerminalTabsStorage.copyFrom.TerminalArrangementManager.252&quot;: &quot;true&quot;,
&quot;RunOnceActivity.git.unshallow&quot;: &quot;true&quot;,
&quot;RunOnceActivity.go.formatter.settings.were.checked&quot;: &quot;true&quot;,
&quot;RunOnceActivity.go.modules.go.list.on.any.changes.was.set&quot;: &quot;true&quot;,
&quot;RunOnceActivity.typescript.service.memoryLimit.init&quot;: &quot;true&quot;,
&quot;com.intellij.ml.llm.matterhorn.ej.ui.settings.DefaultModelSelectionForGA.v1&quot;: &quot;true&quot;,
&quot;git-widget-placeholder&quot;: &quot;main&quot;,
&quot;junie.onboarding.icon.badge.shown&quot;: &quot;true&quot;,
&quot;last_opened_file_path&quot;: &quot;/home/sebastianu/GolandProjects/MarkdownToIHKChemnits&quot;,
&quot;node.js.detected.package.eslint&quot;: &quot;true&quot;,
&quot;node.js.selected.package.eslint&quot;: &quot;(autodetect)&quot;,
&quot;nodejs_package_manager_path&quot;: &quot;npm&quot;,
&quot;onboarding.tips.debug.path&quot;: &quot;/home/sebastianu/GolandProjects/MarkdownToIHKChemnits/main.go&quot;,
&quot;to.speed.mode.migration.done&quot;: &quot;true&quot;
}
}</component>
<component name="RecentsManager">
<key name="CopyFile.RECENT_KEYS">
<recent name="$PROJECT_DIR$" />
</key>
</component>
<component name="SharedIndexes">
<attachedChunks>
<set>
<option value="bundled-gosdk-72a9cf600ed8-448e06e64ec5-org.jetbrains.plugins.go.sharedIndexes.bundled-GO-253.28294.337" />
<option value="bundled-js-predefined-d6986cc7102b-c7e53b3be11b-JavaScript-GO-253.28294.337" />
</set>
</attachedChunks>
</component>
<component name="TaskManager">
<task active="true" id="Default" summary="Default task">
<changelist id="c64f46d5-641a-468c-8fc1-94edec1f2deb" name="Changes" comment="" />
<created>1774345760438</created>
<option name="number" value="Default" />
<option name="presentableId" value="Default" />
<updated>1774345760438</updated>
</task>
<servers />
</component>
<component name="TypeScriptGeneratedFilesManager">
<option name="version" value="3" />
</component>
<component name="Vcs.Log.Tabs.Properties">
<option name="TAB_STATES">
<map>
<entry key="MAIN">
<value>
<State />
</value>
</entry>
</map>
</option>
</component>
<component name="VcsManagerConfiguration">
<MESSAGE value="Initial commit: added Markdown to IHK Chemnitz PDF converter with core structure and features, including YAML config, Goldmark parser, and PDF renderer." />
<option name="LAST_COMMIT_MESSAGE" value="Initial commit: added Markdown to IHK Chemnitz PDF converter with core structure and features, including YAML config, Goldmark parser, and PDF renderer." />
</component>
<component name="XDebuggerManager">
<breakpoint-manager>
<breakpoints>
<line-breakpoint enabled="true" type="DlvLineBreakpoint">
<url>file://$PROJECT_DIR$/main.go</url>
<line>37</line>
<option name="timeStamp" value="1" />
</line-breakpoint>
</breakpoints>
</breakpoint-manager>
</component>
</project>
BIN
View File
Binary file not shown.
+135 -26
View File
@@ -1,47 +1,156 @@
# Markdown to IHK Chemnitz PDF Converter
# Markdown 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.
Converts Markdown files into compliant project documentation for **IHK Chemnitz**
IT vocational exams (Verordnung 2020).
## 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.
## IHK Formatting Requirements
All rules below are enforced automatically. Deviating from them can lead to
grade deductions (*"kann zu Punktabzug bei der Bewertung führen"*).
| Rule | Value | Note |
|---|---|---|
| Font | Helvetica / Arial, black | PDF-safe equivalent |
| Body size | 12 pt | |
| Heading size | 14 pt, bold | |
| Caption / footnote size | 10 pt | |
| Line spacing | 1½ lines (6.35 mm) | |
| Alignment | Justified (Blocksatz) | |
| Left margin | 3.0 cm | |
| **Right margin** | **4.0 cm** | **Korrekturrand** — examiners write feedback here |
| Top margin | 2.0 cm | |
| Bottom margin | 2.5 cm | |
| Paper | DIN A4, single-sided | |
| Front-matter numbering | Roman, starting at **II** | Title page is unnumbered |
| Body numbering | Arabic, starting at **1** | Centered at the bottom |
## Document Structure
The tool produces all required sections in the mandatory IHK order:
```
1. Title page · no page number
2. Table of contents · Roman II
3. List of abbrev. · Roman (optional, from YAML)
4. Foreword / body · Roman → Arabic via Markdown headings
5. Bibliography · Arabic (auto-sorted AZ)
6. List of tables · Arabic (auto-generated)
7. List of figures · Arabic (auto-generated)
8. Appendix · Arabic (via @Anhang: directives)
9. Glossary · Arabic (optional, from YAML)
10. Declaration · no page number (pre-written legal text)
```
## Installation
Stellen Sie sicher, dass [Go](https://golang.org/) installiert ist.
Requires [Go](https://golang.org/) 1.22 or newer.
```bash
git clone https://github.com/ihk-markdown-renderer
git clone <repository-url>
cd MarkdownToIHKChemnits
go mod tidy
```
## Benutzung
1. Erstellen Sie eine `report.md` mit YAML-Frontmatter (siehe Beispiel).
2. Führen Sie den Konverter aus:
## Usage
```bash
go run . -i report.md -o projektarbeit.pdf
```
## Projektstruktur
| Flag | Default | Description |
|---|---|---|
| `-i` | `report.md` | Input Markdown file |
| `-o` | `projektarbeit.pdf` | Output PDF file |
- `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.
## YAML Front Matter
## Lizenz
Every Markdown file starts with a YAML block that drives the title page,
abbreviation list, and glossary:
```yaml
---
student:
name: "Max Mustermann"
profession: "Fachinformatiker Fachrichtung Anwendungsentwicklung"
company: "Musterfirma GmbH"
supervisor: "Sabine Supervisor"
project:
title: "Title of the project"
period: "Summer 2026"
# Optional — generates Abkürzungsverzeichnis
abbreviations:
- abbr: "API"
meaning: "Application Programming Interface"
- abbr: "DIN"
meaning: "Deutsches Institut für Normung"
# Optional — generates Glossar
glossary:
- term: "Goldmark"
definition: "A CommonMark-compliant Markdown parser written in Go."
---
```
## Markdown Directives
Special directives inside paragraphs control bibliography, appendix, and diagrams:
```markdown
# Vorwort
This section uses Roman page numbers.
# 1. Problem Statement
Any numbered or unknown heading switches to Arabic numbering.
# Inline formatting
**Bold** and *italic* text are preserved in the PDF output.
# Bibliography entries
@Quelle: Author, Title, Publisher, Year
> Quelle: Alternative blockquote syntax also works
# Appendix images
@Anhang: Description | /path/to/image.png
# UML diagram as appendix (rendered via Kroki)
@AnhangUML: Sequence Diagram Title
` ``puml
@startuml
A -> B: request
@enduml
` ``
# Inline diagrams (rendered via Kroki, embedded in body)
` ``mermaid
graph TD
A --> B
` ``
```
Diagrams are rendered via [Kroki.io](https://kroki.io) and cached locally
using the SHA-256 hash of the diagram source as the cache key.
## Project Structure
```
MarkdownToIHKChemnits/
├── main.go Entry point; two-pass rendering pipeline
├── config.go Config struct (student, project, abbreviations, glossary)
├── markdown_parser.go Goldmark AST walker; inline span extraction; directives
├── diagram.go Kroki.io integration (Mermaid, PlantUML, …)
├── pdf_renderer.go IHKRenderer struct; DIN 5008 constants; core helpers
├── pdf_numbering.go NumberingType enum; toRoman()
├── pdf_toc.go Table of contents; list of tables; list of figures
├── pdf_pages.go Title, declaration, bibliography, appendix, abbrev., glossary
└── pdf_content.go Headings, paragraphs, tables, images, list items
```
## License
MIT
+38
View File
@@ -1,5 +1,24 @@
package main
// Config holds all document metadata from the Markdown YAML front matter.
// It drives the title page, abbreviation list, and glossary.
//
// Example front matter:
//
// student:
// name: "Max Mustermann"
// profession: "Fachinformatiker Fachrichtung Anwendungsentwicklung"
// company: "Musterfirma GmbH"
// supervisor: "Sabine Supervisor"
// project:
// title: "Thema der Projektarbeit"
// period: "Sommer 2026"
// abbreviations:
// - abbr: "API"
// meaning: "Application Programming Interface"
// glossary:
// - term: "Agile"
// definition: "Iteratives Vorgehensmodell der Softwareentwicklung"
type Config struct {
Student struct {
Name string `yaml:"name"`
@@ -7,9 +26,28 @@ type Config struct {
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"`
// Abbreviations populates the list of abbreviations placed after the TOC.
Abbreviations []Abbreviation `yaml:"abbreviations"`
// Glossary populates the Glossar section placed before the declaration page.
Glossary []GlossaryEntry `yaml:"glossary"`
}
// Abbreviation is one entry in the list of abbreviations.
type Abbreviation struct {
Abbr string `yaml:"abbr"`
Meaning string `yaml:"meaning"`
}
// GlossaryEntry is one entry in the Glossar section.
type GlossaryEntry struct {
Term string `yaml:"term"`
Definition string `yaml:"definition"`
}
+68
View File
@@ -0,0 +1,68 @@
package main
import (
"bytes"
"compress/zlib"
"crypto/sha256"
"encoding/base64"
"fmt"
"io"
"net/http"
"os"
"path/filepath"
)
// RenderDiagramViaKroki converts a diagram source (Mermaid, PlantUML, etc.)
// to a PNG image using the Kroki.io public rendering service and caches the
// result in the OS temp directory.
//
// The cache key is the SHA-256 hash of the diagram source, so unchanged
// diagrams are not re-fetched between runs.
//
// Supported languages: "mermaid", "plantuml" / "puml"
func RenderDiagramViaKroki(lang, code string) (string, error) {
if lang == "puml" {
lang = "plantuml"
}
// Kroki encoding: deflate (zlib) then base64url
var buf bytes.Buffer
w := zlib.NewWriter(&buf)
if _, err := w.Write([]byte(code)); err != nil {
return "", fmt.Errorf("zlib write: %w", err)
}
if err := w.Close(); err != nil {
return "", fmt.Errorf("zlib close: %w", err)
}
encoded := base64.URLEncoding.EncodeToString(buf.Bytes())
url := fmt.Sprintf("https://kroki.io/%s/png/%s", lang, encoded)
// Deterministic cache path based on content hash
hash := fmt.Sprintf("%x", sha256.Sum256([]byte(code)))
cachePath := filepath.Join(os.TempDir(), "ihk_kroki_"+hash+".png")
if _, err := os.Stat(cachePath); err == nil {
return cachePath, nil // cache hit
}
resp, err := http.Get(url) //nolint:gosec // URL is constructed from user content, acceptable here
if err != nil {
return "", fmt.Errorf("kroki request: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return "", fmt.Errorf("kroki returned HTTP %d", resp.StatusCode)
}
out, err := os.Create(cachePath)
if err != nil {
return "", fmt.Errorf("cache file create: %w", err)
}
defer out.Close()
if _, err = io.Copy(out, resp.Body); err != nil {
return "", fmt.Errorf("cache file write: %w", err)
}
return cachePath, nil
}
BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 52 KiB

BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.7 KiB

BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.6 KiB

BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.7 KiB

Binary file not shown.
+51 -39
View File
@@ -4,6 +4,8 @@ import (
"flag"
"fmt"
"log"
"github.com/yuin/goldmark/ast"
)
func main() {
@@ -13,49 +15,59 @@ func main() {
config, doc, content, err := ParseMarkdown(*inputMd)
if err != nil {
log.Fatalf("Error parsing markdown: %v", err)
log.Fatalf("Failed to parse input: %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)
// Pass 1 — dummy render to collect page numbers for the TOC, tables, and figures.
// The recorded page numbers are used to fill the TOC in pass 2.
pass1 := NewIHKRenderer(config)
if err = renderPipeline(pass1, doc, content); err != nil {
log.Fatalf("Pass 1 failed: %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)
// Pass 2 — final render with the TOC index collected in pass 1.
// Only tocItems must be pre-seeded because RenderTOC() runs before RenderAST().
// tableItems and figureItems are re-populated during RenderAST() in pass 2,
// so they must NOT be copied here (that would produce duplicates in the lists).
pass2 := NewIHKRenderer(config)
pass2.tocItems = pass1.tocItems
if err = renderPipeline(pass2, doc, content); err != nil {
log.Fatalf("Pass 2 failed: %v", err)
}
fmt.Printf("Successfully generated %s\n", *outputPdf)
if err = pass2.Save(*outputPdf); err != nil {
log.Fatalf("Failed to save PDF: %v", err)
}
fmt.Printf("PDF created: %s\n", *outputPdf)
}
// renderPipeline executes the full document rendering sequence in the mandatory
// IHK Chemnitz order:
//
// 1. Title page (no page number)
// 2. Table of contents (Roman II)
// 3. List of abbrev. (Roman, from YAML — optional)
// 4. Body via AST walk (foreword Roman → main body Arabic from 1)
// 5. Bibliography (Arabic, AZ sorted)
// 6. List of tables (Arabic)
// 7. List of figures (Arabic)
// 8. Appendix / annex (Arabic)
// 9. Glossary (Arabic, from YAML — optional)
// 10. Declaration (no page number)
func renderPipeline(r *IHKRenderer, doc ast.Node, content []byte) error {
r.RenderTitlePage()
r.RenderTOC()
r.RenderAbbreviations()
if err := RenderAST(doc, content, r); err != nil {
return err
}
r.RenderBibliography()
r.RenderListOfTables()
r.RenderListOfFigures()
r.RenderAppendices()
r.RenderGlossary()
r.RenderDeclarationPage()
return nil
}
+211 -115
View File
@@ -2,25 +2,22 @@ package main
import (
"bytes"
"compress/zlib"
"crypto/sha256"
"encoding/base64"
"fmt"
"os"
"strings"
"github.com/yuin/goldmark"
"github.com/yuin/goldmark-meta"
meta "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"
)
// ParseMarkdown reads a Markdown file, extracts the YAML front matter into a
// Config and returns the parsed AST together with the raw source bytes.
func ParseMarkdown(mdPath string) (Config, ast.Node, []byte, error) {
content, err := os.ReadFile(mdPath)
if err != nil {
@@ -31,146 +28,287 @@ func ParseMarkdown(mdPath string) (Config, ast.Node, []byte, error) {
goldmark.WithExtensions(meta.Meta, extension.Table),
)
context := parser.NewContext()
doc := md.Parser().Parse(text.NewReader(content), parser.WithContext(context))
ctx := parser.NewContext()
doc := md.Parser().Parse(text.NewReader(content), parser.WithContext(ctx))
metaData := meta.Get(context)
// Convert metaData map to Config struct
metaData := meta.Get(ctx)
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)
raw, _ := yaml.Marshal(metaData)
if err = yaml.Unmarshal(raw, &config); err != nil {
return Config{}, nil, nil, fmt.Errorf("YAML front matter: %w", err)
}
return config, doc, content, nil
}
// parserState tracks transient state during the AST walk.
type parserState struct {
nextCodeIsAppendix bool
appendixTitle string
listStack []listFrame // stack for nested list tracking
}
// listFrame tracks the type and item counter for one list nesting level.
type listFrame struct {
ordered bool
index int
}
// RenderAST walks the Goldmark AST and dispatches to IHKRenderer methods.
//
// Front-matter detection: a level-1 heading that is not a numbered section
// and has a name in the front-matter list ("Vorwort", "Abkürzungsverzeichnis")
// stays in Roman-numeral territory. All other level-1 headings trigger
// StartMainBody() and switch to Arabic page numbering.
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) {
switch node := n.(type) {
// ── Headings ──────────────────────────────────────────────────────────
case *ast.Heading:
if !entering {
return ast.WalkContinue, nil
}
switch node := n.(type) {
case *ast.Heading:
title := extractPlainText(node, content)
if node.Level == 1 && r.numType == NumRoman {
title := extractText(node, content)
if title != "Vorwort" && title != "Abkürzungsverzeichnis" {
if !isFrontMatterSection(title) {
r.StartMainBody()
}
}
title := extractText(node, content)
r.RenderHeader(node.Level, title)
return ast.WalkSkipChildren, nil
// ── Paragraphs ────────────────────────────────────────────────────────
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 !entering {
return ast.WalkContinue, nil
}
}
if isMeta {
plain := extractPlainText(node, content)
// Special directives embedded in paragraphs
if handled := handleDirectives(plain, state, r); handled {
return ast.WalkSkipChildren, nil
}
r.RenderParagraph(text)
spans := extractInlineSpans(node, content)
r.RenderParagraphSpans(spans)
return ast.WalkSkipChildren, nil
// ── Fenced code blocks ────────────────────────────────────────────────
case *ast.FencedCodeBlock:
if !entering {
return ast.WalkContinue, nil
}
lang := string(node.Language(content))
code := extractCode(node, content)
code := extractCodeBlock(node, content)
if lang == "mermaid" || lang == "plantuml" || lang == "puml" {
imgPath, err := RenderDiagramViaKroki(lang, code)
if err == nil {
caption := lang
if state.nextCodeIsAppendix {
r.AddAppendix(state.appendixTitle + " | " + imgPath)
state.nextCodeIsAppendix = false
} else {
r.RenderImage(imgPath, "Diagramm: "+lang)
r.RenderImage(imgPath, "Diagram ("+caption+")")
}
return ast.WalkSkipChildren, nil
}
// Fall through: render as plain code block on error
}
// Render non-diagram code blocks as monospace paragraphs
r.RenderParagraphSpans([]InlineSpan{{Text: code, Code: true}})
return ast.WalkSkipChildren, nil
// ── Images ────────────────────────────────────────────────────────────
case *ast.Image:
if !entering {
return ast.WalkContinue, nil
}
imgPath := string(node.Destination)
title := string(node.Title)
r.RenderImage(imgPath, title)
caption := extractPlainText(node, content)
if caption == "" {
caption = string(node.Title)
}
r.RenderImage(imgPath, caption)
return ast.WalkSkipChildren, nil
// ── Block quotes (alternative @Quelle syntax) ─────────────────────────
case *ast.Blockquote:
// Check if first paragraph starts with "Quelle:"
if !entering {
return ast.WalkContinue, nil
}
first := node.FirstChild()
if first != nil {
if para, ok := first.(*ast.Paragraph); ok {
pText := extractText(para, content)
pText := extractPlainText(para, content)
if strings.HasPrefix(pText, "Quelle:") || strings.HasPrefix(pText, "Source:") {
sourceText := strings.TrimPrefix(pText, "Quelle:")
sourceText = strings.TrimPrefix(sourceText, "Source:")
r.AddSource(sourceText)
src := strings.TrimPrefix(strings.TrimPrefix(pText, "Quelle:"), "Source:")
r.AddSource(src)
return ast.WalkSkipChildren, nil
}
}
}
return ast.WalkContinue, nil
// ── Lists ─────────────────────────────────────────────────────────────
case *ast.List:
// Items will be handled by ListItem
if entering {
state.listStack = append(state.listStack, listFrame{ordered: node.IsOrdered()})
} else {
if len(state.listStack) > 0 {
state.listStack = state.listStack[:len(state.listStack)-1]
}
}
return ast.WalkContinue, nil
case *ast.ListItem:
text := extractText(node, content)
r.RenderListItem(text, true, 0) // Basic bullet point for now
if !entering {
return ast.WalkContinue, nil
}
depth := len(state.listStack) - 1
if depth < 0 {
depth = 0
}
frame := &state.listStack[depth]
frame.index++
spans := extractInlineSpansFromListItem(node, content)
r.RenderListItem(spans, frame.ordered, frame.index, depth)
return ast.WalkSkipChildren, nil
// ── Tables ────────────────────────────────────────────────────────────
case *extast.Table:
if !entering {
return ast.WalkContinue, nil
}
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))
rowData = append(rowData, extractPlainText(cell, content))
}
tableData = append(tableData, rowData)
}
r.RenderTable(tableData)
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"
// isFrontMatterSection returns true for level-1 headings that belong to the
// Roman-numbered front matter (before the main body begins).
func isFrontMatterSection(title string) bool {
switch strings.TrimSpace(title) {
case "Vorwort", "Einleitung", "Abkürzungsverzeichnis":
return true
}
} else {
textStr += extractText(child, content)
}
}
if textStr == "" {
// Fallback for simple nodes
return string(n.Text(content))
}
return textStr
// Everything else (including numbered sections like "1. Problem Statement")
// belongs to the Arabic-numbered main body.
return false
}
func extractCode(n *ast.FencedCodeBlock, content []byte) string {
// handleDirectives processes special @-prefixed control lines in a paragraph.
// Returns true if the paragraph was fully consumed as a directive.
func handleDirectives(text string, state *parserState, r *IHKRenderer) bool {
lines := strings.Split(text, "\n")
handled := false
for _, line := range lines {
line = strings.TrimSpace(line)
switch {
case strings.HasPrefix(line, "@Quelle:"):
r.AddSource(strings.TrimSpace(strings.TrimPrefix(line, "@Quelle:")))
handled = true
case strings.HasPrefix(line, "@Anhang:"):
r.AddAppendix(strings.TrimSpace(strings.TrimPrefix(line, "@Anhang:")))
handled = true
case strings.HasPrefix(line, "@AnhangUML:"):
state.nextCodeIsAppendix = true
state.appendixTitle = strings.TrimSpace(strings.TrimPrefix(line, "@AnhangUML:"))
handled = true
}
}
return handled
}
// extractPlainText returns the plain-text content of an AST node by
// recursively concatenating all text leaf nodes.
func extractPlainText(n ast.Node, content []byte) string {
var sb strings.Builder
for child := n.FirstChild(); child != nil; child = child.NextSibling() {
if t, ok := child.(*ast.Text); ok {
sb.Write(t.Segment.Value(content))
if t.SoftLineBreak() || t.HardLineBreak() {
sb.WriteByte('\n')
}
} else {
sb.WriteString(extractPlainText(child, content))
}
}
if sb.Len() == 0 {
return string(n.Text(content))
}
return sb.String()
}
// extractInlineSpans walks the children of a paragraph node and builds a
// slice of InlineSpan values that preserve bold, italic, and code formatting.
func extractInlineSpans(n ast.Node, content []byte) []InlineSpan {
var spans []InlineSpan
walkInline(n, content, false, false, false, &spans)
return spans
}
// extractInlineSpansFromListItem extracts spans from the first paragraph child
// of a list item, which is how Goldmark represents list item content.
func extractInlineSpansFromListItem(item *ast.ListItem, content []byte) []InlineSpan {
for child := item.FirstChild(); child != nil; child = child.NextSibling() {
if _, ok := child.(*ast.Paragraph); ok {
return extractInlineSpans(child, content)
}
// TextBlock is used for tight lists
return extractInlineSpans(child, content)
}
return nil
}
// walkInline recursively collects InlineSpan values from an AST subtree,
// propagating bold/italic context down through Emphasis nodes.
func walkInline(n ast.Node, content []byte, bold, italic, code bool, out *[]InlineSpan) {
for child := n.FirstChild(); child != nil; child = child.NextSibling() {
switch c := child.(type) {
case *ast.Text:
seg := string(c.Segment.Value(content))
if c.SoftLineBreak() {
seg += " "
} else if c.HardLineBreak() {
seg += "\n"
}
if seg != "" {
*out = append(*out, InlineSpan{Text: seg, Bold: bold, Italic: italic, Code: code})
}
case *ast.Emphasis:
childBold := bold || c.Level == 2
childItalic := italic || c.Level == 1
walkInline(c, content, childBold, childItalic, code, out)
case *ast.CodeSpan:
raw := string(c.Text(content))
*out = append(*out, InlineSpan{Text: raw, Bold: bold, Italic: italic, Code: true})
case *ast.Link:
// Render link text; the href is not shown (no footnote support yet).
walkInline(c, content, bold, italic, code, out)
default:
walkInline(c, content, bold, italic, code, out)
}
}
}
// extractCodeBlock returns the raw source text of a fenced code block.
func extractCodeBlock(n *ast.FencedCodeBlock, content []byte) string {
var buf bytes.Buffer
for i := 0; i < n.Lines().Len(); i++ {
line := n.Lines().At(i)
@@ -178,45 +316,3 @@ func extractCode(n *ast.FencedCodeBlock, content []byte) string {
}
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
}
+269
View File
@@ -0,0 +1,269 @@
package main
import (
"fmt"
"strconv"
"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()
indentMM := float64(indent+1) * 8.0
r.pdf.SetX(lm + indentMM)
prefix := "• "
if ordered {
prefix = strconv.Itoa(index) + ". "
}
hasFormatting := false
for _, s := range spans {
if s.Bold || s.Italic || s.Code {
hasFormatting = true
break
}
}
uw := r.usableWidth() - indentMM
if !hasFormatting {
text := prefix
for _, s := range spans {
text += s.Text
}
r.pdf.MultiCell(uw, dinLineHtBody, r.tr(text), "", "L", false)
} else {
r.pdf.Write(dinLineHtBody, r.tr(prefix))
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)
}
// 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 ""
}
}
+27
View File
@@ -0,0 +1,27 @@
package main
// NumberingType controls the page numbering style rendered in the footer.
type NumberingType int
const (
NumNone NumberingType = iota // No page number (title page, declaration)
NumRoman // Roman numerals for front matter (II, III, …)
NumArabic // Arabic numerals for main body (1, 2, …)
)
// toRoman converts a positive integer to an uppercase Roman numeral string.
func toRoman(n int) string {
if n <= 0 {
return ""
}
vals := []int{1000, 900, 500, 400, 100, 90, 50, 40, 10, 9, 5, 4, 1}
syms := []string{"M", "CM", "D", "CD", "C", "XC", "L", "XL", "X", "IX", "V", "IV", "I"}
result := ""
for i, v := range vals {
for n >= v {
n -= v
result += syms[i]
}
}
return result
}
+249
View File
@@ -0,0 +1,249 @@
package main
import (
"fmt"
"sort"
"github.com/go-pdf/fpdf"
)
// RenderTitlePage renders the cover page without a page number.
// Layout follows the IHK Chemnitz template (section 2.2 of the guidelines).
func (r *IHKRenderer) RenderTitlePage() {
r.numType = NumNone
r.pdf.AddPage()
// Use symmetrical margins on the title page for visual centering.
r.pdf.SetLeftMargin(dinMarginLeft)
r.pdf.SetRightMargin(dinMarginLeft)
defer func() {
r.pdf.SetLeftMargin(dinMarginLeft)
r.pdf.SetRightMargin(dinMarginRight)
}()
// Exam type and profession
r.pdf.SetFont("Helvetica", "B", 16)
r.pdf.CellFormat(0, 12, r.tr("Abschlussprüfung zum"), "", 1, "C", false, 0, "")
r.pdf.CellFormat(0, 12, r.tr(r.config.Student.Profession), "", 1, "C", false, 0, "")
r.pdf.Ln(20)
r.pdf.SetFont("Helvetica", "", dinFontBody)
r.pdf.CellFormat(0, dinLineHtBody, r.tr("Projektarbeit von"), "", 1, "C", false, 0, "")
r.pdf.Ln(4)
// Candidate name — bold, 14 pt
r.pdf.SetFont("Helvetica", "B", dinFontHeading)
r.pdf.CellFormat(0, dinLineHtHeading, r.tr(r.config.Student.Name), "", 1, "C", false, 0, "")
r.pdf.Ln(20)
// Project title — bold, 16 pt, centred, word-wrapped
r.pdf.SetFont("Helvetica", "B", 16)
r.pdf.MultiCell(0, 10, r.tr(r.config.Project.Title), "", "C", false)
if r.config.Project.Subtitle != "" {
r.pdf.Ln(4)
r.pdf.SetFont("Helvetica", "I", dinFontBody)
r.pdf.MultiCell(0, dinLineHtBody, r.tr(r.config.Project.Subtitle), "", "C", false)
}
// Bottom block: exam period, training company, supervisor
r.pdf.SetY(-80)
r.pdf.SetFont("Helvetica", "", dinFontBody)
labelW := 60.0
r.pdf.CellFormat(labelW, dinLineHtBody, r.tr("Prüfungsperiode:"), "", 0, "L", false, 0, "")
r.pdf.MultiCell(0, dinLineHtBody, r.tr(r.config.Project.Period), "", "L", false)
r.pdf.CellFormat(labelW, dinLineHtBody, r.tr("Ausbildungsbetrieb:"), "", 0, "L", false, 0, "")
r.pdf.MultiCell(0, dinLineHtBody, r.tr(r.config.Student.Company), "", "L", false)
r.pdf.CellFormat(labelW, dinLineHtBody, r.tr("Projektbetreuer:"), "", 0, "L", false, 0, "")
r.pdf.MultiCell(0, dinLineHtBody, r.tr(r.config.Student.Supervisor), "", "L", false)
}
// RenderDeclarationPage renders the declaration of authenticity without a page
// number, as required by IHK Chemnitz (section 2.12 of the guidelines).
// This page must be physically inserted in every submitted copy.
func (r *IHKRenderer) RenderDeclarationPage() {
r.numType = NumNone
r.pdf.AddPage()
r.pdf.SetFont("Helvetica", "B", dinFontHeading)
r.pdf.CellFormat(0, dinLineHtHeading+4, r.tr("Erklärung"), "", 1, "C", false, 0, "")
r.pdf.Ln(dinSpaceAfterHeading)
// Legally required declaration text — must remain in German.
r.pdf.SetFont("Helvetica", "", dinFontBody)
declaration := 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, dinLineHtBody, r.tr(declaration), "", "J", false)
r.pdf.Ln(dinLineHtBody)
additional := "Mir ist bekannt, dass gemäß der Prüfungsordnung für die Durchführung" +
" von Abschlussprüfungen der Industrie- und Handelskammer Chemnitz" +
" Täuschungshandlungen zum Ausschluss von der Prüfung führen können und" +
" die Prüfung als nicht bestanden erklärt werden kann."
r.pdf.MultiCell(0, dinLineHtBody, r.tr(additional), "", "J", false)
r.pdf.Ln(20)
r.pdf.CellFormat(0, dinLineHtBody, r.tr("Ort, Datum, Unterschrift (mit Vor- und Nachnamen)"), "", 1, "L", false, 0, "")
r.pdf.Ln(4)
// Signature line
r.pdf.SetDrawColor(0, 0, 0)
x, y := r.pdf.GetXY()
r.pdf.Line(x, y, x+80, y)
}
// RenderBibliography renders the bibliography sorted alphabetically per IHK
// rule 2.8.1. Skipped when no @Quelle directives were found in the document.
func (r *IHKRenderer) RenderBibliography() {
if len(r.sources) == 0 {
return
}
r.RenderHeader(1, "Literaturverzeichnis")
sorted := make([]string, len(r.sources))
copy(sorted, r.sources)
sort.Strings(sorted)
r.pdf.SetFont("Helvetica", "", dinFontBody)
for _, source := range sorted {
r.pdf.MultiCell(0, dinLineHtBody, r.tr(" "+source), "", "J", false)
r.pdf.Ln(2)
}
}
// RenderAppendices renders the appendix section followed by individual annex
// pages. The opening page contains the index of all annexes. Each annex image
// is scaled to fill the available page area.
func (r *IHKRenderer) RenderAppendices() {
if len(r.appendices) == 0 {
return
}
r.RenderHeader(1, "Anhang")
// Annex index
r.pdf.SetFont("Helvetica", "B", dinFontBody)
r.pdf.CellFormat(0, dinLineHtBody, r.tr("Anlagenverzeichnis"), "", 1, "L", false, 0, "")
r.pdf.Ln(dinSpaceAfterHeading)
r.pdf.SetFont("Helvetica", "", dinFontBody)
for i, app := range r.appendices {
r.pdf.CellFormat(0, dinLineHtBody,
r.tr(fmt.Sprintf("Anlage %d: %s", i+1, app.Title)), "", 1, "L", false, 0, "")
}
// Individual annex pages
for i, app := range r.appendices {
r.pdf.AddPage()
r.pdf.SetFont("Helvetica", "B", dinFontBody)
r.pdf.CellFormat(0, dinLineHtBody,
r.tr(fmt.Sprintf("Anlage %d: %s", i+1, app.Title)), "", 1, "L", false, 0, "")
r.pdf.Ln(dinSpaceAfterHeading)
r.renderAppendixImage(app.Path)
}
}
// renderAppendixImage scales and embeds an image to fill the remaining page area.
func (r *IHKRenderer) renderAppendixImage(path string) {
info := r.ensureImageRegistered(path)
if info == nil {
r.pdf.CellFormat(0, dinLineHtBody, r.tr("[Image could not be loaded: "+path+"]"),
"1", 1, "C", false, 0, "")
return
}
_, pageH := r.pdf.GetPageSize()
lm, _, _, bm := r.pdf.GetMargins()
uw := r.usableWidth()
availH := pageH - r.pdf.GetY() - bm - 10
imgW := info.Width() * ptToMM
imgH := info.Height() * ptToMM
// Scale to fill available width, then clamp to available height.
displayW := uw
displayH := imgH * (displayW / imgW)
if displayH > availH {
scale := availH / displayH
displayH = availH
displayW = displayW * scale
}
posX := lm + (uw-displayW)/2
r.pdf.ImageOptions(path, posX, r.pdf.GetY(), displayW, displayH, false,
fpdf.ImageOptions{ReadDpi: true}, 0, "")
}
// RenderAbbreviations renders the list of abbreviations from the YAML config.
// It is placed after the TOC and uses Roman page numbering.
func (r *IHKRenderer) RenderAbbreviations() {
if len(r.config.Abbreviations) == 0 {
return
}
r.pdf.AddPage()
r.RecordHeader(1, "Abkürzungsverzeichnis")
r.pdf.SetFont("Helvetica", "B", dinFontHeading)
r.pdf.CellFormat(0, dinLineHtHeading+4, r.tr("Abkürzungsverzeichnis"), "", 1, "L", false, 0, "")
r.pdf.Ln(dinSpaceAfterHeading)
uw := r.usableWidth()
abbrColW := 40.0
meaningColW := uw - abbrColW
// Header row with grey fill
r.pdf.SetFillColor(230, 230, 230)
r.pdf.SetFont("Helvetica", "B", dinFontBody)
r.pdf.CellFormat(abbrColW, dinLineHtBody, r.tr("Abkürzung"), "1", 0, "L", true, 0, "")
r.pdf.CellFormat(meaningColW, dinLineHtBody, r.tr("Bedeutung"), "1", 1, "L", true, 0, "")
r.pdf.SetFillColor(255, 255, 255)
r.pdf.SetFont("Helvetica", "", dinFontBody)
for _, a := range r.config.Abbreviations {
r.pdf.CellFormat(abbrColW, dinLineHtBody, r.tr(a.Abbr), "1", 0, "L", false, 0, "")
r.pdf.CellFormat(meaningColW, dinLineHtBody, r.tr(a.Meaning), "1", 1, "L", false, 0, "")
}
}
// RenderGlossary renders the glossary from the YAML config.
// It is placed after the appendices and uses Arabic page numbering.
func (r *IHKRenderer) RenderGlossary() {
if len(r.config.Glossary) == 0 {
return
}
r.RenderHeader(1, "Glossar")
for _, entry := range r.config.Glossary {
r.pdf.SetFont("Helvetica", "B", dinFontBody)
r.pdf.CellFormat(0, dinLineHtBody, r.tr(entry.Term), "", 1, "L", false, 0, "")
r.pdf.SetFont("Helvetica", "", dinFontBody)
r.pdf.MultiCell(0, dinLineHtBody, r.tr(entry.Definition), "", "J", false)
r.pdf.Ln(4)
}
}
// ensureImageRegistered registers an image with fpdf if not already known
// and returns its metadata. Returns nil if the image cannot be loaded.
func (r *IHKRenderer) ensureImageRegistered(path string) *fpdf.ImageInfoType {
info := r.pdf.GetImageInfo(path)
if info == nil {
r.pdf.RegisterImageOptions(path, fpdf.ImageOptions{ReadDpi: true})
info = r.pdf.GetImageInfo(path)
}
return info
}
// ptToMM converts PDF points to millimetres (1 pt = 25.4 / 72 mm).
const ptToMM = 25.4 / 72.0
+89 -467
View File
@@ -1,56 +1,78 @@
// 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
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,
@@ -58,467 +80,57 @@ func NewIHKRenderer(config Config) *IHKRenderer {
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
}
+167
View File
@@ -0,0 +1,167 @@
package main
import "strconv"
// TOCItem is one entry in the Inhaltsverzeichnis.
type TOCItem struct {
Level int
Title string
PageStr string // formatted page number (Roman or Arabic)
}
// TableItem is one entry in the Tabellenverzeichnis.
type TableItem struct {
Title string
PageStr string
}
// FigureItem is one entry in the Abbildungsverzeichnis.
type FigureItem struct {
Title string
PageStr string
}
// dirEntry is the internal representation used by renderDirEntries.
type dirEntry struct {
indent int // indentation level (0 = none)
title string // translated title text
pageStr string // page number string
}
// RecordHeader adds a heading to the TOC with the appropriate page number.
// Front-matter headings use Roman numerals; main-body headings use Arabic.
// Headings on the title page (NumNone) are not recorded.
func (r *IHKRenderer) RecordHeader(level int, title string) {
var pageStr string
switch r.numType {
case NumRoman:
pageStr = toRoman(r.pdf.PageNo())
case NumArabic:
dp := r.pdf.PageNo() - r.pageOffset
if dp <= 0 {
dp = 1
}
pageStr = strconv.Itoa(dp)
default:
return
}
r.tocItems = append(r.tocItems, TOCItem{Level: level, Title: title, PageStr: pageStr})
}
// RecordTable adds an entry to the Tabellenverzeichnis.
func (r *IHKRenderer) RecordTable(title string) {
dp := r.pdf.PageNo() - r.pageOffset
if dp <= 0 {
dp = 1
}
r.tableItems = append(r.tableItems, TableItem{Title: title, PageStr: strconv.Itoa(dp)})
}
// RecordFigure adds an entry to the Abbildungsverzeichnis.
func (r *IHKRenderer) RecordFigure(title string) {
dp := r.pdf.PageNo() - r.pageOffset
if dp <= 0 {
dp = 1
}
r.figureItems = append(r.figureItems, FigureItem{Title: title, PageStr: strconv.Itoa(dp)})
}
// RenderTOC renders the Inhaltsverzeichnis on a new Roman-numbered page.
// In pass 1 the list is empty (no tocItems yet); pass 2 fills it in.
// Note: if the TOC spans more pages than in pass 1, subsequent page numbers
// will be off by the difference — an inherent two-pass limitation.
func (r *IHKRenderer) RenderTOC() {
r.numType = NumRoman
r.pdf.AddPage()
r.pdf.SetFont("Helvetica", "B", dinFontHeading)
r.pdf.CellFormat(0, dinLineHtHeading+4, r.tr("Inhaltsverzeichnis"), "", 1, "L", false, 0, "")
r.pdf.Ln(dinSpaceAfterHeading)
if len(r.tocItems) == 0 {
return
}
// Column header row
uw := r.usableWidth()
r.pdf.SetFont("Helvetica", "B", dinFontBody)
r.pdf.CellFormat(uw-20, dinLineHtBody, r.tr("Inhalt"), "", 0, "L", false, 0, "")
r.pdf.CellFormat(20, dinLineHtBody, r.tr("Seite"), "", 1, "R", false, 0, "")
r.pdf.Ln(2)
entries := make([]dirEntry, len(r.tocItems))
for i, item := range r.tocItems {
entries[i] = dirEntry{
indent: item.Level - 1,
title: item.Title,
pageStr: item.PageStr,
}
}
r.renderDirEntries(entries)
}
// RenderListOfTables renders the Tabellenverzeichnis if any tables were recorded.
func (r *IHKRenderer) RenderListOfTables() {
if len(r.tableItems) == 0 {
return
}
r.pdf.AddPage()
r.pdf.SetFont("Helvetica", "B", dinFontHeading)
r.pdf.CellFormat(0, dinLineHtHeading+4, r.tr("Tabellenverzeichnis"), "", 1, "L", false, 0, "")
r.pdf.Ln(dinSpaceAfterHeading)
entries := make([]dirEntry, len(r.tableItems))
for i, item := range r.tableItems {
entries[i] = dirEntry{indent: 0, title: item.Title, pageStr: item.PageStr}
}
r.renderDirEntries(entries)
}
// RenderListOfFigures renders the Abbildungsverzeichnis if any figures were recorded.
func (r *IHKRenderer) RenderListOfFigures() {
if len(r.figureItems) == 0 {
return
}
r.pdf.AddPage()
r.pdf.SetFont("Helvetica", "B", dinFontHeading)
r.pdf.CellFormat(0, dinLineHtHeading+4, r.tr("Abbildungsverzeichnis"), "", 1, "L", false, 0, "")
r.pdf.Ln(dinSpaceAfterHeading)
entries := make([]dirEntry, len(r.figureItems))
for i, item := range r.figureItems {
entries[i] = dirEntry{indent: 0, title: item.Title, pageStr: item.PageStr}
}
r.renderDirEntries(entries)
}
// renderDirEntries renders a list of title/page entries with dot leaders,
// per DIN 5008 Inhaltsverzeichnis layout.
func (r *IHKRenderer) renderDirEntries(entries []dirEntry) {
uw := r.usableWidth()
lm, _, _, _ := r.pdf.GetMargins()
r.pdf.SetFont("Helvetica", "", dinFontBody)
for _, e := range entries {
indent := float64(e.indent * 8)
r.pdf.SetX(lm + indent)
title := r.tr(e.title)
pageStr := e.pageStr
tw := r.pdf.GetStringWidth(title)
pw := r.pdf.GetStringWidth(pageStr)
avail := uw - indent - tw - pw - 6
r.pdf.CellFormat(tw+2, dinLineHtBody, title, "", 0, "L", false, 0, "")
if avail > 0 {
dots := ""
dw := r.pdf.GetStringWidth(".")
if dw > 0 {
for float64(len([]rune(dots)))*dw < avail {
dots += "."
}
}
r.pdf.CellFormat(avail, dinLineHtBody, dots, "", 0, "L", false, 0, "")
}
r.pdf.CellFormat(pw+4, dinLineHtBody, pageStr, "", 1, "R", false, 0, "")
}
}
BIN
View File
Binary file not shown.
+69 -43
View File
@@ -7,78 +7,104 @@ student:
project:
title: "Entwicklung eines Markdown-zu-IHK-Konverters"
subtitle: "Projektdokumentation zur Abschlussprüfung"
period: "Frühjahr 2026"
period: "Sommer 2026"
abbreviations:
- abbr: "IHK"
meaning: "Industrie- und Handelskammer"
- abbr: "PDF"
meaning: "Portable Document Format"
- abbr: "API"
meaning: "Application Programming Interface"
- abbr: "AST"
meaning: "Abstract Syntax Tree"
- abbr: "DIN"
meaning: "Deutsches Institut für Normung"
glossary:
- term: "Goldmark"
definition: "Ein in Go geschriebener Markdown-Parser, der den CommonMark-Standard implementiert."
- term: "FPDF"
definition: "Eine Go-Bibliothek zur Erzeugung von PDF-Dokumenten ohne externe Abhängigkeiten."
- term: "Kroki"
definition: "Ein Webdienst, der verschiedene Diagramm-Beschreibungssprachen (Mermaid, PlantUML u.a.) in Bilder umwandelt."
---
# Vorwort
Dieses Projekt entstand im Rahmen der Abschlussprüfung...
Dieses Projekt entstand im Rahmen der Abschlussprüfung zum Fachinformatiker
Fachrichtung Anwendungsentwicklung. Es soll zeigen, dass technische Dokumentationen
effizient und normgerecht erstellt werden können.
# 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 ß.
Aktuell müssen IHK-Dokumentationen mühsam in Word formatiert werden, was
fehleranfällig ist und viel Zeit kostet. Besonders die Einhaltung der
Formvorgaben Schriftgröße, Zeilenabstand und Seitenränder erfordert
manuelle Sorgfalt bei jedem Absatz.
## 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.
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,
- die Qualität der Dokumente *einheitlich* sicherstellen und
- den Prozess vollständig automatisieren.
# 2. Projektablauf
## 2.1 Planung
Die Planung umfasst die Analyse der IHK-Vorgaben und das Design der Software-Architektur.
### Architektur-Übersicht (Mermaid)
Die Planung umfasst die Analyse der IHK-Vorgaben und das Design der
Software-Architektur. Der Projektzeitraum beträgt maximal 80 Stunden
gemäß Ausbildungsverordnung.
### Architektur-Übersicht
```mermaid
graph TD
A[Markdown] --> B(Go Parser)
B --> C{Metadata?}
B --> C{Metadaten?}
C -->|Ja| D[Config]
C -->|Nein| E[Default]
C -->|Nein| E[Standard]
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
F --> G[IHK-konformes PDF]
```
## 2.2 Realisierung
Die Realisierung erfolgt in Go unter Verwendung von `goldmark` und `fpdf`.
| Tool | Zweck |
|------|-------|
| Go | Programmiersprache |
| Goldmark | Markdown Parser |
| FPDF | PDF Renderer |
Die Realisierung erfolgt in Go unter Verwendung von `goldmark` und `fpdf`.
Der Konverter verarbeitet die Markdown-Datei in zwei Durchläufen, um das
Inhaltsverzeichnis korrekt mit Seitenangaben zu befüllen.
| Werkzeug | Zweck | Version |
|----------|-------|---------|
| Go | Programmiersprache | 1.22+ |
| Goldmark | Markdown-Parser (AST) | v1.8 |
| FPDF | PDF-Erzeugung | v0.9 |
| Kroki | Diagramm-Rendering | online |
@Quelle: Goldmark Documentation, https://github.com/yuin/goldmark, 2024
> Quelle: Go-PDF/Fpdf Documentation, https://github.com/go-pdf/fpdf, 2025
@Quelle: Go-PDF/Fpdf Documentation, https://github.com/go-pdf/fpdf, 2025
## 2.3 Test und Qualitätssicherung
Das Tool wurde anhand eines Musterdokuments getestet. Folgende Kriterien
wurden geprüft:
1. Korrekte Seitenränder gemäß DIN 5008
2. Seitennummerierung (römisch im Vorspann, arabisch im Textteil)
3. Schriftart Helvetica, 12 Punkt, 1½-zeilig
4. Automatische Abbildungs- und Tabellenverzeichnisse
# 3. Zusammenfassung
Das Tool ermöglicht eine effiziente Erstellung von Dokumentationen unter Einhaltung aller Formatvorgaben.
Das Tool ermöglicht eine effiziente Erstellung von IHK-Dokumentationen unter
Einhaltung **aller** Formatvorgaben. Durch die Trennung von Inhalt (Markdown)
und Formatierung (Go-Renderer) ist eine konsistente Ausgabe garantiert.
@Quelle: IHK Chemnitz, Hinweise zur Erarbeitung der Dokumentation, 2020
@Quelle: IHK Chemnitz, Hinweise zur Erarbeitung der Dokumentation über die Projektarbeit, 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
```
@Anhang: Architektur-Diagramm Übersicht | test.png
-98
View File
@@ -1,98 +0,0 @@
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.