Add support for appendices: landscape diagrams, tables, and images; implement Kroki URL configurability; enhance directive parsing logic.

This commit is contained in:
Sebastian Unterschütz
2026-05-12 21:44:37 +02:00
parent 436cdcc516
commit 67f9d63f24
12 changed files with 1025 additions and 221 deletions
+12 -4
View File
@@ -4,10 +4,16 @@
<option name="autoReloadType" value="ALL" />
</component>
<component name="ChangeListManager">
<list default="true" id="c64f46d5-641a-468c-8fc1-94edec1f2deb" name="Changes" comment="Remove outdated `toc_pages.txt`, add new Go modules for IHK Chemnitz PDF rendering including diagrams, tables, and TOC functionality.">
<list default="true" id="c64f46d5-641a-468c-8fc1-94edec1f2deb" name="Changes" comment="Refactor PDF content rendering: improve list indentation logic, add numbered code block rendering with gutter, and update text wrapping alignment.">
<change beforePath="$PROJECT_DIR$/.idea/workspace.xml" beforeDir="false" afterPath="$PROJECT_DIR$/.idea/workspace.xml" afterDir="false" />
<change beforePath="$PROJECT_DIR$/MarkdownToIHKChemnits" beforeDir="false" afterPath="$PROJECT_DIR$/MarkdownToIHKChemnits" afterDir="false" />
<change beforePath="$PROJECT_DIR$/README.md" beforeDir="false" afterPath="$PROJECT_DIR$/README.md" afterDir="false" />
<change beforePath="$PROJECT_DIR$/diagram.go" beforeDir="false" afterPath="$PROJECT_DIR$/diagram.go" afterDir="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_content.go" beforeDir="false" afterPath="$PROJECT_DIR$/pdf_content.go" afterDir="false" />
<change beforePath="$PROJECT_DIR$/pdf_pages.go" beforeDir="false" afterPath="$PROJECT_DIR$/pdf_pages.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" />
</list>
@@ -17,7 +23,7 @@
<option name="LAST_RESOLUTION" value="IGNORE" />
</component>
<component name="EmbeddingIndexingInfo">
<option name="cachedIndexableFilesCount" value="19" />
<option name="cachedIndexableFilesCount" value="22" />
<option name="fileBasedEmbeddingIndicesEnabled" value="true" />
</component>
<component name="GOROOT" url="file:///usr/lib/go" />
@@ -95,14 +101,16 @@
<MESSAGE value="Initial commit: added Markdown to IHK Chemnitz PDF converter with core structure and features, including YAML config, Goldmark parser, and PDF renderer." />
<MESSAGE value="Remove outdated IHK guideline text and refactor PDF renderer for improved modularity, DIN 5008 compliance, and glossary/abbreviation support." />
<MESSAGE value="Remove outdated `toc_pages.txt`, add new Go modules for IHK Chemnitz PDF rendering including diagrams, tables, and TOC functionality." />
<option name="LAST_COMMIT_MESSAGE" value="Remove outdated `toc_pages.txt`, add new Go modules for IHK Chemnitz PDF rendering including diagrams, tables, and TOC functionality." />
<MESSAGE value="Refactor PDF content rendering: improve list indentation logic, add numbered code block rendering with gutter, and update text wrapping alignment." />
<MESSAGE value="Add table annex handling, update diagram rendering with configurable Kroki URL, and improve Markdown directive parsing" />
<option name="LAST_COMMIT_MESSAGE" value="Add table annex handling, update diagram rendering with configurable Kroki URL, and improve Markdown directive parsing" />
</component>
<component name="XDebuggerManager">
<breakpoint-manager>
<breakpoints>
<line-breakpoint enabled="true" type="DlvLineBreakpoint">
<url>file://$PROJECT_DIR$/main.go</url>
<line>37</line>
<line>40</line>
<option name="timeStamp" value="1" />
</line-breakpoint>
</breakpoints>
Binary file not shown.
+284 -95
View File
@@ -1,156 +1,345 @@
# MarkdownIHK Chemnitz PDF Converter
# MarkdownToIHKChemnitz
Converts Markdown files into compliant project documentation for **IHK Chemnitz**
IT vocational exams (Verordnung 2020).
A Go CLI tool that converts a single Markdown file into a print-ready PDF compliant with the **IHK Chemnitz project documentation guidelines** (Verordnung 2020) and **DIN 5008** formatting rules.
## Features
- DIN 5008 page layout — correct margins, font, line spacing, Blocksatz
- Two-pass rendering for a page-accurate table of contents
- Roman/Arabic page numbering with automatic section detection
- Multi-line table cells with uniform row height per row
- Named tables (*Tabellenverzeichnis*) and captioned figures (*Abbildungsverzeichnis*)
- Numbered code blocks with line-number gutter and long-line wrapping
- Diagram rendering via [Kroki](https://kroki.io) — Mermaid, PlantUML, and more
- Inline landscape diagram pages (`@DiagrammQuer:`)
- Appendices in portrait **and landscape** — images, tables, and diagrams
- Abbreviation list and glossary from YAML front matter
- Bibliography sorted alphabetically from inline `@Quelle:` directives
- Declaration of authenticity page (pre-written legal text)
---
## IHK Formatting Requirements
## Prerequisites
All rules below are enforced automatically. Deviating from them can lead to
grade deductions (*"kann zu Punktabzug bei der Bewertung führen"*).
- Go 1.22 or later
- Internet access **or** a local Kroki instance for diagram rendering (see below)
| 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
Requires [Go](https://golang.org/) 1.22 or newer.
## Build & Run
```bash
git clone <repository-url>
cd MarkdownToIHKChemnits
go mod tidy
go build -o ihk-pdf .
./ihk-pdf -i report.md -o projektarbeit.pdf
```
## Usage
Or without building:
```bash
go run . -i report.md -o projektarbeit.pdf
```
---
## CLI Flags
| Flag | Default | Description |
|---|---|---|
|------|---------|-------------|
| `-i` | `report.md` | Input Markdown file |
| `-o` | `projektarbeit.pdf` | Output PDF file |
| `-kroki` | `https://kroki.io` | Kroki base URL for diagram rendering |
---
## Diagram Rendering with Kroki
Mermaid and PlantUML fenced code blocks are rendered server-side via [Kroki](https://kroki.io). The resulting PNG is cached locally using a SHA-256 hash of the diagram source, so unchanged diagrams are not re-fetched.
By default the public instance at `https://kroki.io` is used. If your network blocks it, run a local instance with Docker Compose:
**`docker-compose.yml`**
```yaml
services:
kroki:
image: yuzutech/kroki
environment:
- KROKI_MERMAID_HOST=mermaid
ports:
- "8000:8000"
depends_on:
- mermaid
mermaid:
image: yuzutech/kroki-mermaid
expose:
- "8002"
```
```bash
docker compose up -d
go run . -i report.md -kroki http://localhost:8000 -o projektarbeit.pdf
```
> **Note:** The base `yuzutech/kroki` image does not include a Mermaid renderer. The companion `yuzutech/kroki-mermaid` container is required, wired via `KROKI_MERMAID_HOST=mermaid`.
---
## YAML Front Matter
Every Markdown file starts with a YAML block that drives the title page,
abbreviation list, and glossary:
Every input file begins with a YAML block delimited by `---`. All fields are optional except where noted.
```yaml
---
student:
name: "Max Mustermann"
name: "Max Mustermann" # required
profession: "Fachinformatiker Fachrichtung Anwendungsentwicklung"
company: "Musterfirma GmbH"
supervisor: "Sabine Supervisor"
project:
title: "Title of the project"
period: "Summer 2026"
# Optional — generates Abkürzungsverzeichnis
abbreviations:
project:
title: "Titel der Projektarbeit" # required
subtitle: "Optionaler Untertitel" # optional
period: "Sommer 2026"
abbreviations: # optional → Abkürzungsverzeichnis
- abbr: "API"
meaning: "Application Programming Interface"
- abbr: "DIN"
meaning: "Deutsches Institut für Normung"
- abbr: "IHK"
meaning: "Industrie- und Handelskammer"
# Optional — generates Glossar
glossary:
glossary: # optional → Glossar (after appendices)
- term: "Goldmark"
definition: "A CommonMark-compliant Markdown parser written in Go."
definition: "Ein CommonMark-konformer Markdown-Parser für Go."
---
```
## Markdown Directives
---
Special directives inside paragraphs control bibliography, appendix, and diagrams:
## Document Structure
```markdown
# Vorwort
This section uses Roman page numbers.
The generated PDF follows the mandatory IHK Chemnitz order:
# 1. Problem Statement
Any numbered or unknown heading switches to Arabic numbering.
| # | Section | Page Numbering |
|---|---------|----------------|
| 1 | Title page | none |
| 2 | Table of contents | Roman (II, III, …) |
| 3 | Abbreviation list | Roman (from YAML, optional) |
| 4 | Foreword / front-matter sections | Roman |
| 5 | Main body chapters | Arabic (1, 2, …) |
| 6 | Bibliography | Arabic |
| 7 | List of tables | Arabic |
| 8 | List of figures | Arabic |
| 9 | Appendices | Arabic |
| 10 | Glossary | Arabic (from YAML, optional) |
| 11 | Declaration of authenticity | none |
# Inline formatting
**Bold** and *italic* text are preserved in the PDF output.
### Front-matter detection
# Bibliography entries
@Quelle: Author, Title, Publisher, Year
> Quelle: Alternative blockquote syntax also works
Level-1 headings named `Vorwort`, `Einleitung`, or `Abkürzungsverzeichnis` stay in the Roman-numbered front matter. Every other level-1 heading triggers the switch to Arabic numbering.
# Appendix images
@Anhang: Description | /path/to/image.png
---
# UML diagram as appendix (rendered via Kroki)
@AnhangUML: Sequence Diagram Title
` ``puml
## DIN 5008 / IHK Formatting Rules
| Property | Value |
|----------|-------|
| Paper | A4 |
| Left margin | 30 mm |
| Right margin (Korrekturrand) | 40 mm |
| Top margin | 20 mm |
| Bottom margin | 25 mm |
| Body font | Helvetica (Arial) 12 pt, black |
| Body line spacing | 1.5× → 6.35 mm |
| Heading font | Helvetica Bold 14 pt |
| Caption / footnote | Helvetica 10 pt |
| List line spacing | 1.2× → 5.0 mm |
| Alignment | Justified (Blocksatz) |
| Page number position | Centered at bottom |
---
## Directives
Directives are plain paragraphs starting with `@`. They are consumed by the parser and never appear in the PDF as raw text. Multiple directives may appear in a single paragraph, one per line.
---
### `@Quelle:` — Bibliography entry
```
@Quelle: Autor, Titel, Verlag, Jahr
```
Registers a bibliography entry. All entries are collected and rendered alphabetically at the end of the document in the *Literaturverzeichnis*. May appear anywhere in the document, any number of times.
---
### `@Tabelle:` — Named table
Place immediately before a Markdown table to assign it a name and record it in the *Tabellenverzeichnis*:
```
@Tabelle: Übersicht der Programmiersprachen
| Sprache | Paradigma | Typsystem |
|---------|-----------|-----------|
| Go | Imperativ | Statisch |
| Python | Multi | Dynamisch |
```
---
### `@TabelleAnhang:` — Table as portrait appendix
Sends the following table to the appendix on a **portrait** A4 page as a numbered *Anlage*:
```
@TabelleAnhang: Vollständige Fehlerliste
| Code | Beschreibung | Schwere |
|------|--------------------|----------|
| E001 | Datei nicht gefunden | Kritisch |
```
---
### `@TabelleAnhangQuer:` — Table as landscape appendix
Same as `@TabelleAnhang:` but placed on a **landscape** A4 page (297 × 210 mm, 15 mm symmetric margins). Use for wide tables that do not fit in portrait:
```
@TabelleAnhangQuer: Breite Vergleichsmatrix
| Kriterium | Option A | Option B | Option C | Option D |
|-----------|----------|----------|----------|----------|
| Leistung | Gut | Sehr gut | Befriedigend | Gut |
```
---
### `@Anhang:` — Image as portrait appendix
```
@Anhang: Netzwerkdiagramm | diagrams/network.png
```
Adds an image file as a numbered *Anlage* on a **portrait** appendix page. The format is `Title | relative/path/to/image`.
---
### `@AnhangBildQuer:` — Image as landscape appendix
```
@AnhangBildQuer: Großes Architekturdiagramm | diagrams/arch.png
```
Same as `@Anhang:` but placed on a **landscape** A4 page. Useful for wide images.
---
### `@AnhangUML:` — Diagram as portrait appendix
Renders the immediately following Mermaid or PlantUML code block via Kroki and places the result as a numbered *Anlage* on a **portrait** appendix page:
```
@AnhangUML: Datenbankschema
` ``plantuml
@startuml
A -> B: request
entity User {
+ id : int
+ name : string
}
@enduml
` ``
```
---
### `@AnhangUMLQuer:` — Diagram as landscape appendix
Same as `@AnhangUML:` but placed on a **landscape** A4 page with 15 mm symmetric margins. Use for complex diagrams that need more horizontal space:
```
@AnhangUMLQuer: Vollständige Modulübersicht
# Inline diagrams (rendered via Kroki, embedded in body)
` ``mermaid
graph TD
A --> B
A --> B --> C
` ``
```
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
### `@DiagrammQuer:` — Inline landscape diagram page
Renders the following diagram on a **dedicated landscape page inline** in the document (not in the appendix). A figure caption is added and the figure is recorded in the *Abbildungsverzeichnis*. A fresh portrait page opens automatically afterwards:
```
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
@DiagrammQuer: Systemarchitektur Zwei-Pass-Rendering
` ``mermaid
graph LR
MD[report.md] --> Parser --> AST --> Renderer --> PDF
` ``
```
---
## Directive Summary Table
| Directive | Format | Placement | Orientation |
|-----------|--------|-----------|-------------|
| `@Quelle:` | `@Quelle: Text` | Inline anywhere | — |
| `@Tabelle:` | `@Tabelle: Name` | Before a table | — |
| `@TabelleAnhang:` | `@TabelleAnhang: Name` | Before a table | Portrait appendix |
| `@TabelleAnhangQuer:` | `@TabelleAnhangQuer: Name` | Before a table | **Landscape** appendix |
| `@Anhang:` | `@Anhang: Title \| path` | Standalone | Portrait appendix |
| `@AnhangBildQuer:` | `@AnhangBildQuer: Title \| path` | Standalone | **Landscape** appendix |
| `@AnhangUML:` | `@AnhangUML: Title` | Before diagram block | Portrait appendix |
| `@AnhangUMLQuer:` | `@AnhangUMLQuer: Title` | Before diagram block | **Landscape** appendix |
| `@DiagrammQuer:` | `@DiagrammQuer: Caption` | Before diagram block | **Landscape** inline page |
---
## File Structure
```
.
├── main.go # Entry point, CLI flags, two-pass pipeline
├── config.go # Config struct matching the YAML front matter
├── markdown_parser.go # Goldmark AST walker, directive handling
├── pdf_renderer.go # IHKRenderer struct, DIN 5008 constants, appendix registry
├── pdf_content.go # Paragraphs, lists, tables, images, code blocks
├── pdf_toc.go # TOC, list of tables, list of figures
├── pdf_pages.go # Title page, declaration, bibliography, appendices, glossary
├── pdf_numbering.go # Roman/Arabic page numbering helpers
├── diagram.go # Kroki HTTP client, SHA-256 image cache
├── docker-compose.yml # Local Kroki + kroki-mermaid containers
└── report.md # Sample document demonstrating all features
```
---
## Two-Pass Rendering
The tool renders the document twice:
1. **Pass 1** — full render into a scratch PDF to collect the page number of every heading, table, and figure.
2. **Pass 2** — final render using the TOC index from pass 1, producing the output PDF.
Only `tocItems` are transferred between passes. `tableItems` and `figureItems` are rebuilt during pass 2 to avoid duplicates in the respective lists.
---
## Landscape Pages — Technical Notes
- Landscape appendices and `@DiagrammQuer:` use `fpdf.AddPageFormat("L", {Wd:297, Ht:210})` with 15 mm symmetric margins. No 40 mm Korrekturrand — examiners do not annotate diagram or data pages.
- After any landscape page, `AddPage()` reverts to the default portrait orientation (A4 "P") set at construction time — no explicit orientation restore is needed for subsequent content.
- Portrait margins (30/20/40 mm) are restored immediately after the landscape page is closed so all following content is formatted correctly.
---
## License
MIT
+17 -9
View File
@@ -10,15 +10,22 @@ import (
"net/http"
"os"
"path/filepath"
"time"
)
// krokiBaseURL is the Kroki instance to use. Override with the -kroki flag.
// Self-host: docker run -d -p 8000:8000 yuzutech/kroki
var krokiBaseURL = "https://kroki.io"
// krokiClient is a shared HTTP client with a short timeout so a dead Kroki
// backend fails fast instead of hanging for ~60 s per diagram.
var krokiClient = &http.Client{Timeout: 10 * time.Second}
// 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.
// to a PNG image via a Kroki rendering service and caches the result in the
// OS temp directory. The base URL is controlled by krokiBaseURL (-kroki flag).
//
// Cache key: SHA-256 of the diagram source — unchanged diagrams are not re-fetched.
// Supported languages: "mermaid", "plantuml" / "puml"
func RenderDiagramViaKroki(lang, code string) (string, error) {
if lang == "puml" {
@@ -35,7 +42,7 @@ func RenderDiagramViaKroki(lang, code string) (string, error) {
return "", fmt.Errorf("zlib close: %w", err)
}
encoded := base64.URLEncoding.EncodeToString(buf.Bytes())
url := fmt.Sprintf("https://kroki.io/%s/png/%s", lang, encoded)
url := fmt.Sprintf("%s/%s/png/%s", krokiBaseURL, lang, encoded)
// Deterministic cache path based on content hash
hash := fmt.Sprintf("%x", sha256.Sum256([]byte(code)))
@@ -45,14 +52,14 @@ func RenderDiagramViaKroki(lang, code string) (string, error) {
return cachePath, nil // cache hit
}
resp, err := http.Get(url) //nolint:gosec // URL is constructed from user content, acceptable here
resp, err := krokiClient.Get(url) //nolint:gosec
if err != nil {
return "", fmt.Errorf("kroki request: %w", err)
return "", fmt.Errorf("kroki request failed (is %s reachable?): %w", krokiBaseURL, err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return "", fmt.Errorf("kroki returned HTTP %d", resp.StatusCode)
return "", fmt.Errorf("kroki returned HTTP %d — try a local instance: docker run -d -p 8000:8000 yuzutech/kroki", resp.StatusCode)
}
out, err := os.Create(cachePath)
@@ -62,6 +69,7 @@ func RenderDiagramViaKroki(lang, code string) (string, error) {
defer out.Close()
if _, err = io.Copy(out, resp.Body); err != nil {
_ = os.Remove(cachePath) // remove incomplete cache file
return "", fmt.Errorf("cache file write: %w", err)
}
return cachePath, nil
+14
View File
@@ -0,0 +1,14 @@
services:
kroki:
image: yuzutech/kroki
environment:
- KROKI_MERMAID_HOST=mermaid
ports:
- "8000:8000"
depends_on:
- mermaid
mermaid:
image: yuzutech/kroki-mermaid
expose:
- "8002"
+3
View File
@@ -11,8 +11,11 @@ import (
func main() {
inputMd := flag.String("i", "report.md", "Input Markdown file")
outputPdf := flag.String("o", "projektarbeit.pdf", "Output PDF file")
kroki := flag.String("kroki", "https://kroki.io", "Kroki base URL (e.g. http://localhost:8000 for a local instance)")
flag.Parse()
krokiBaseURL = *kroki
config, doc, content, err := ParseMarkdown(*inputMd)
if err != nil {
log.Fatalf("Failed to parse input: %v", err)
+74 -9
View File
@@ -3,6 +3,7 @@ package main
import (
"bytes"
"fmt"
"log"
"os"
"strings"
@@ -43,9 +44,15 @@ func ParseMarkdown(mdPath string) (Config, ast.Node, []byte, error) {
// parserState tracks transient state during the AST walk.
type parserState struct {
nextCodeIsAppendix bool
appendixTitle string
listStack []listFrame // stack for nested list tracking
nextCodeIsAppendix bool
nextAppendixLandscape bool // set by @AnhangUMLQuer: — landscape for diagram appendix
appendixTitle string
nextTableCaption string // set by @Tabelle: directive
nextTableIsAppendix bool // set by @TabelleAnhang: or @TabelleAnhangQuer:
nextTableIsLandscape bool // set by @TabelleAnhangQuer:
nextDiagramLandscape bool // set by @DiagrammQuer: directive
nextDiagramCaption string // caption for the landscape diagram page
listStack []listFrame // stack for nested list tracking
}
// listFrame tracks the type and item counter for one list nesting level.
@@ -107,13 +114,29 @@ func RenderAST(doc ast.Node, content []byte, r *IHKRenderer) error {
if lang == "mermaid" || lang == "plantuml" || lang == "puml" {
imgPath, err := RenderDiagramViaKroki(lang, code)
if err != nil {
log.Printf("warning: diagram render failed (%s): %v — falling back to code block", lang, err)
state.nextDiagramLandscape = false
state.nextDiagramCaption = ""
state.nextCodeIsAppendix = false
state.nextAppendixLandscape = false
}
if err == nil {
caption := lang
if state.nextCodeIsAppendix {
r.AddAppendix(state.appendixTitle + " | " + imgPath)
switch {
case state.nextDiagramLandscape:
r.RenderLandscapeDiagram(imgPath, state.nextDiagramCaption)
state.nextDiagramLandscape = false
state.nextDiagramCaption = ""
case state.nextCodeIsAppendix:
if state.nextAppendixLandscape {
r.AddLandscapeAppendix(state.appendixTitle + " | " + imgPath)
state.nextAppendixLandscape = false
} else {
r.AddAppendix(state.appendixTitle + " | " + imgPath)
}
state.nextCodeIsAppendix = false
} else {
r.RenderImage(imgPath, "Diagram ("+caption+")")
default:
r.RenderImage(imgPath, "Diagram ("+lang+")")
}
return ast.WalkSkipChildren, nil
}
@@ -162,6 +185,11 @@ func RenderAST(doc ast.Node, content []byte, r *IHKRenderer) error {
if len(state.listStack) > 0 {
state.listStack = state.listStack[:len(state.listStack)-1]
}
// Add breathing room after the outermost list so the next
// paragraph is not glued to the last bullet.
if len(state.listStack) == 0 {
r.pdf.Ln(dinSpaceAfterList)
}
}
return ast.WalkContinue, nil
@@ -192,7 +220,20 @@ func RenderAST(doc ast.Node, content []byte, r *IHKRenderer) error {
}
tableData = append(tableData, rowData)
}
r.RenderTable(tableData, "")
if state.nextTableIsAppendix {
if state.nextTableIsLandscape {
r.AddTableAppendixLandscape(state.nextTableCaption, tableData)
state.nextTableIsLandscape = false
} else {
r.AddTableAppendix(state.nextTableCaption, tableData)
}
state.nextTableIsAppendix = false
state.nextTableCaption = ""
} else {
caption := state.nextTableCaption
state.nextTableCaption = ""
r.RenderTable(tableData, caption)
}
return ast.WalkSkipChildren, nil
}
@@ -226,10 +267,34 @@ func handleDirectives(text string, state *parserState, r *IHKRenderer) bool {
case strings.HasPrefix(line, "@Anhang:"):
r.AddAppendix(strings.TrimSpace(strings.TrimPrefix(line, "@Anhang:")))
handled = true
case strings.HasPrefix(line, "@AnhangBildQuer:"):
r.AddLandscapeAppendix(strings.TrimSpace(strings.TrimPrefix(line, "@AnhangBildQuer:")))
handled = true
case strings.HasPrefix(line, "@AnhangUMLQuer:"):
state.nextCodeIsAppendix = true
state.nextAppendixLandscape = true
state.appendixTitle = strings.TrimSpace(strings.TrimPrefix(line, "@AnhangUMLQuer:"))
handled = true
case strings.HasPrefix(line, "@AnhangUML:"):
state.nextCodeIsAppendix = true
state.appendixTitle = strings.TrimSpace(strings.TrimPrefix(line, "@AnhangUML:"))
handled = true
case strings.HasPrefix(line, "@TabelleAnhangQuer:"):
state.nextTableIsAppendix = true
state.nextTableIsLandscape = true
state.nextTableCaption = strings.TrimSpace(strings.TrimPrefix(line, "@TabelleAnhangQuer:"))
handled = true
case strings.HasPrefix(line, "@TabelleAnhang:"):
state.nextTableIsAppendix = true
state.nextTableCaption = strings.TrimSpace(strings.TrimPrefix(line, "@TabelleAnhang:"))
handled = true
case strings.HasPrefix(line, "@Tabelle:"):
state.nextTableCaption = strings.TrimSpace(strings.TrimPrefix(line, "@Tabelle:"))
handled = true
case strings.HasPrefix(line, "@DiagrammQuer:"):
state.nextDiagramLandscape = true
state.nextDiagramCaption = strings.TrimSpace(strings.TrimPrefix(line, "@DiagrammQuer:"))
handled = true
}
}
return handled
+169 -56
View File
@@ -2,6 +2,7 @@ package main
import (
"fmt"
"log"
"strconv"
"strings"
@@ -111,7 +112,7 @@ func (r *IHKRenderer) RenderListItem(spans []InlineSpan, ordered bool, index, in
// Render bullet/number; CellFormat advances X to textX automatically.
r.pdf.SetX(lm + indentMM)
r.pdf.CellFormat(prefixW, dinLineHtBody, r.tr(prefix), "", 0, "L", false, 0, "")
r.pdf.CellFormat(prefixW, dinLineHtList, r.tr(prefix), "", 0, "L", false, 0, "")
hasFormatting := false
for _, s := range spans {
@@ -126,7 +127,7 @@ func (r *IHKRenderer) RenderListItem(spans []InlineSpan, ordered bool, index, in
for _, s := range spans {
text += s.Text
}
r.pdf.MultiCell(textW, dinLineHtBody, r.tr(text), "", "L", false)
r.pdf.MultiCell(textW, dinLineHtList, r.tr(text), "", "L", false)
} else {
for _, span := range spans {
style := fontStyle(span.Bold, span.Italic)
@@ -135,70 +136,149 @@ func (r *IHKRenderer) RenderListItem(spans []InlineSpan, ordered bool, index, in
} else {
r.pdf.SetFont("Helvetica", style, dinFontBody)
}
r.pdf.Write(dinLineHtBody, r.tr(span.Text))
r.pdf.Write(dinLineHtList, r.tr(span.Text))
}
r.pdf.Ln(dinLineHtBody)
r.pdf.Ln(dinLineHtList)
}
}
// 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.
// tableRowData holds pre-computed line-wrapped content for one table row.
type tableRowData struct {
cells [][]string // wrapped lines per cell
height float64 // total row height in mm
}
// prepareRow measures how many lines each cell needs and returns a tableRowData
// with the wrapped content. Font must be set for measurement before calling.
func (r *IHKRenderer) prepareRow(rawCells []string, numCols int, colW, lineHt float64, bold bool) tableRowData {
style := ""
if bold {
style = "B"
}
r.pdf.SetFont("Helvetica", style, dinFontCaption)
cells := make([][]string, numCols)
maxLines := 0
for j := 0; j < numCols; j++ {
raw := ""
if j < len(rawCells) {
raw = rawCells[j]
}
split := r.pdf.SplitLines([]byte(r.tr(raw)), colW-2)
lines := make([]string, len(split))
for k, b := range split {
lines[k] = string(b)
}
if len(lines) == 0 {
lines = []string{""}
}
cells[j] = lines
if len(lines) > maxLines {
maxLines = len(lines)
}
}
if maxLines == 0 {
maxLines = 1
}
return tableRowData{cells: cells, height: float64(maxLines) * lineHt}
}
// drawRow renders a pre-computed row at the current Y position.
// Cell borders are drawn as rectangles so all cells share a uniform height
// regardless of how many lines they contain.
func (r *IHKRenderer) drawRow(row tableRowData, numCols int, colW, lineHt float64, isHeader bool) {
startY := r.pdf.GetY()
lm, _, _, _ := r.pdf.GetMargins()
style := ""
align := "L"
if isHeader {
style = "B"
align = "C"
r.pdf.SetFillColor(230, 230, 230)
} else {
r.pdf.SetFillColor(255, 255, 255)
}
// Draw all cell backgrounds and borders first (uniform height via Rect).
for j := 0; j < numCols; j++ {
r.pdf.Rect(lm+float64(j)*colW, startY, colW, row.height, "FD")
}
// Render text on top, line by line per cell.
r.pdf.SetFont("Helvetica", style, dinFontCaption)
for j, cellLines := range row.cells {
x := lm + float64(j)*colW
for k, line := range cellLines {
r.pdf.SetXY(x+1, startY+float64(k)*lineHt)
r.pdf.CellFormat(colW-2, lineHt, line, "", 0, align, false, 0, "")
}
}
r.pdf.SetY(startY + row.height)
}
// RenderTable numbers a table, records it in the Tabellenverzeichnis, and
// renders it with multi-line cell support and an auto-repeating header on
// page breaks. data[0] is the header row; data[1:] are body rows.
func (r *IHKRenderer) RenderTable(data [][]string, caption string) {
if len(data) == 0 {
return
}
r.tableCount++
label := fmt.Sprintf("Tab. %d", r.tableCount)
if caption != "" {
label += ": " + caption
}
r.RecordTable(label)
r.renderTableBody(data, label)
}
// renderTableBody renders table content without assigning a number or recording
// in the Tabellenverzeichnis. Pass an empty label to suppress the caption line.
func (r *IHKRenderer) renderTableBody(data [][]string, label string) {
if len(data) == 0 {
return
}
header := data[0]
numCols := len(header)
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)
if numCols == 0 {
return
}
renderHeaderRow(false)
r.pdf.SetFont("Helvetica", "", dinFontCaption)
r.pdf.SetFillColor(255, 255, 255)
uw := r.usableWidth()
colW := uw / float64(numCols)
lineHt := dinLineHtCaption + 2
_, pageH := r.pdf.GetPageSize()
_, _, _, bm := r.pdf.GetMargins()
rowH := dinLineHtCaption + 2
// Pre-compute all rows so we know heights before rendering.
headerRow := r.prepareRow(header, numCols, colW, lineHt, true)
bodyRows := make([]tableRowData, len(data)-1)
for i := 1; i < len(data); i++ {
if r.pdf.GetY()+rowH > pageH-bm {
bodyRows[i-1] = r.prepareRow(data[i], numCols, colW, lineHt, false)
}
renderHeader := func(continued bool) {
title := label
if continued && label != "" {
title += " (Fortsetzung)"
}
if title != "" {
r.pdf.SetFont("Helvetica", "B", dinFontCaption)
r.pdf.CellFormat(0, lineHt, r.tr(title), "", 1, "L", false, 0, "")
}
r.drawRow(headerRow, numCols, colW, lineHt, true)
}
renderHeader(false)
for _, row := range bodyRows {
if r.pdf.GetY()+row.height > pageH-bm {
r.pdf.AddPage()
renderHeaderRow(true)
r.pdf.SetFont("Helvetica", "", dinFontCaption)
renderHeader(true)
}
for _, col := range data[i] {
r.pdf.CellFormat(colW, rowH, r.tr(col), "1", 0, "L", false, 0, "")
}
r.pdf.Ln(-1)
r.drawRow(row, numCols, colW, lineHt, false)
}
r.pdf.Ln(dinSpaceAfterParagraph)
}
@@ -212,6 +292,7 @@ func (r *IHKRenderer) RenderImage(path string, caption string) {
info := r.ensureImageRegistered(path)
if info == nil {
log.Printf("warning: image not found, rendering placeholder: %q", path)
r.pdf.SetFont("Helvetica", "I", dinFontCaption)
r.pdf.CellFormat(0, dinLineHtCaption, r.tr("[Bild nicht gefunden: "+path+"]"),
"1", 1, "C", false, 0, "")
@@ -283,11 +364,13 @@ const (
// │ 1 │ package main │
// │ 2 │ │
// │ 3 │ func main() { … } │
// │ 4 │ // long line wraps here … │
// │ │ … continuation │
// └──────┴──────────────────────────────────┘
//
// Lines that exceed the column width are wrapped. Continuation rows show
// an empty gutter so the source line number stays unambiguous.
// The language label is shown in small italic text above the block.
// Lines that exceed the printable width are clipped — code should be
// formatted to reasonable lengths before conversion.
func (r *IHKRenderer) RenderCodeBlock(lang, code string) {
lines := strings.Split(strings.TrimRight(code, "\n"), "\n")
if len(lines) == 0 {
@@ -311,22 +394,33 @@ func (r *IHKRenderer) RenderCodeBlock(lang, code string) {
r.pdf.SetFont("Courier", "", codeFont)
// In Courier every glyph has the same advance width; one measurement suffices.
// Reserve 1 mm right padding so characters never touch the column edge.
charW := r.pdf.GetStringWidth("0")
maxChars := int((codeW-1)/charW) - 1 // -1 for the leading space we prepend
if maxChars < 1 {
maxChars = 1
}
for i, line := range lines {
// Start a new page if this line would fall below the bottom margin.
if r.pdf.GetY()+codeLineHt > pageH-bm {
r.pdf.AddPage()
chunks := wrapCodeLine(line, maxChars)
for ci, chunk := range chunks {
if r.pdf.GetY()+codeLineHt > pageH-bm {
r.pdf.AddPage()
}
// Gutter: darker grey; line number on the first chunk, blank on continuations.
r.pdf.SetFillColor(218, 218, 218)
r.pdf.SetX(lm)
if ci == 0 {
r.pdf.CellFormat(codeGutterW, codeLineHt,
fmt.Sprintf("%4d ", i+1), "", 0, "R", true, 0, "")
} else {
r.pdf.CellFormat(codeGutterW, codeLineHt, "", "", 0, "R", true, 0, "")
}
// Code column: lighter grey, left-aligned, small leading space.
r.pdf.SetFillColor(246, 246, 246)
r.pdf.CellFormat(codeW, codeLineHt, r.tr(" "+chunk), "", 1, "L", true, 0, "")
}
// Gutter: darker grey, right-aligned line number.
r.pdf.SetFillColor(218, 218, 218)
r.pdf.SetX(lm)
r.pdf.CellFormat(codeGutterW, codeLineHt,
fmt.Sprintf("%4d ", i+1), "", 0, "R", true, 0, "")
// Code line: lighter grey, left-aligned, small leading space.
r.pdf.SetFillColor(246, 246, 246)
r.pdf.CellFormat(codeW, codeLineHt,
r.tr(" "+line), "", 1, "L", true, 0, "")
}
// Reset colours so subsequent content is unaffected.
@@ -334,6 +428,25 @@ func (r *IHKRenderer) RenderCodeBlock(lang, code string) {
r.pdf.Ln(dinSpaceAfterParagraph)
}
// wrapCodeLine splits a source code line into chunks of at most maxChars runes.
// A single-rune minimum prevents an infinite loop on extremely narrow columns.
func wrapCodeLine(line string, maxChars int) []string {
runes := []rune(line)
if len(runes) <= maxChars {
return []string{line}
}
var chunks []string
for len(runes) > 0 {
n := maxChars
if n > len(runes) {
n = len(runes)
}
chunks = append(chunks, string(runes[:n]))
runes = runes[n:]
}
return chunks
}
// fontStyle returns the fpdf font style string for a given bold/italic combination.
func fontStyle(bold, italic bool) string {
switch {
+108 -4
View File
@@ -2,6 +2,7 @@ package main
import (
"fmt"
"log"
"sort"
"github.com/go-pdf/fpdf"
@@ -126,8 +127,8 @@ func (r *IHKRenderer) RenderBibliography() {
}
// 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.
// pages. The opening page contains the index of all annexes. Annexes marked
// Landscape=true are placed on a landscape A4 page with 15 mm symmetric margins.
func (r *IHKRenderer) RenderAppendices() {
if len(r.appendices) == 0 {
return
@@ -144,14 +145,41 @@ func (r *IHKRenderer) RenderAppendices() {
r.tr(fmt.Sprintf("Anlage %d: %s", i+1, app.Title)), "", 1, "L", false, 0, "")
}
// Save portrait dimensions and standard margins for post-landscape restoration.
portraitW, portraitH := r.pdf.GetPageSize()
lm, tm, rm, _ := r.pdf.GetMargins()
const diagMargin = 15.0
// Individual annex pages
for i, app := range r.appendices {
r.pdf.AddPage()
if app.Landscape {
r.pdf.SetMargins(diagMargin, diagMargin, diagMargin)
r.pdf.SetAutoPageBreak(true, diagMargin)
// portraitH > portraitW → {Wd: portraitH, Ht: portraitW} = landscape 297×210.
r.pdf.AddPageFormat("L", fpdf.SizeType{Wd: portraitH, Ht: portraitW})
} else {
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)
switch app.Kind {
case AppendixKindImage:
r.renderAppendixImage(app.Path)
case AppendixKindTable:
// Render without numbering/recording; the annex header already identifies the table.
r.renderTableBody(app.TableData, "")
}
if app.Landscape {
// Restore standard margins so the next AddPage() returns to portrait.
r.pdf.SetMargins(lm, tm, rm)
r.pdf.SetAutoPageBreak(true, dinMarginBottom)
}
}
}
@@ -234,6 +262,79 @@ func (r *IHKRenderer) RenderGlossary() {
}
}
// RenderLandscapeDiagram renders an image on a dedicated landscape A4 page,
// scaled to fill the full printable area. A figure caption is added below the
// image and recorded in the Abbildungsverzeichnis.
//
// The page uses symmetric 15 mm margins (no Korrekturrand — examiners do not
// mark up diagram pages). After rendering, a fresh portrait page is opened so
// subsequent content continues in the correct orientation.
func (r *IHKRenderer) RenderLandscapeDiagram(path, caption string) {
// Current dimensions before the landscape page (portrait A4: 210 × 297 mm).
pw, ph := r.pdf.GetPageSize()
lm, tm, rm, _ := r.pdf.GetMargins()
const diagMargin = 15.0
// Apply symmetric margins before adding the landscape page so the page
// inherits them. The Korrekturrand (40 mm) is not needed on a diagram page.
r.pdf.SetMargins(diagMargin, diagMargin, diagMargin)
r.pdf.SetAutoPageBreak(true, diagMargin)
// ph > pw for portrait A4 → {Wd: ph, Ht: pw} = landscape 297 × 210 mm.
r.pdf.AddPageFormat("L", fpdf.SizeType{Wd: ph, Ht: pw})
captionH := dinLineHtCaption + 4
availW := ph - 2*diagMargin // 297 30 = 267 mm
availH := pw - 2*diagMargin - captionH - 2 // 210 30 ~10 ≈ 170 mm
info := r.ensureImageRegistered(path)
if info == nil {
log.Printf("warning: landscape diagram image not found: %q", path)
r.pdf.SetFont("Helvetica", "I", dinFontCaption)
r.pdf.CellFormat(0, dinLineHtCaption,
r.tr("[Bild nicht gefunden: "+path+"]"), "1", 1, "C", false, 0, "")
} else {
imgW := info.Width() * ptToMM
imgH := info.Height() * ptToMM
// Scale to fill available width, clamp to available height.
displayW := availW
displayH := imgH * (displayW / imgW)
if displayH > availH {
displayH = availH
displayW = imgW * (displayH / imgH)
if displayW > availW {
displayW = availW
displayH = imgH * (displayW / imgW)
}
}
posX := diagMargin + (availW-displayW)/2
posY := diagMargin
r.pdf.ImageOptions(path, posX, posY, displayW, displayH, false,
fpdf.ImageOptions{ReadDpi: true}, 0, "")
r.pdf.SetY(posY + displayH + 2)
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, "")
}
// Restore portrait margins and open a fresh portrait page.
// AddPage() always uses the default orientation ("P") set at construction,
// so subsequent content is guaranteed to be in portrait.
r.pdf.SetMargins(lm, tm, rm)
r.pdf.SetAutoPageBreak(true, dinMarginBottom)
r.pdf.AddPage()
}
// 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 {
@@ -242,6 +343,9 @@ func (r *IHKRenderer) ensureImageRegistered(path string) *fpdf.ImageInfoType {
r.pdf.RegisterImageOptions(path, fpdf.ImageOptions{ReadDpi: true})
info = r.pdf.GetImageInfo(path)
}
if info == nil {
log.Printf("warning: could not load image %q", path)
}
return info
}
+66 -4
View File
@@ -4,6 +4,7 @@
package main
import (
"log"
"strconv"
"strings"
@@ -36,12 +37,28 @@ const (
dinSpaceBeforeHeading = 2 * dinLineHtBody // ≈ 12.7 mm
dinSpaceAfterHeading = dinLineHtBody // ≈ 6.35 mm
dinSpaceAfterParagraph = 4.0 // between body paragraphs
// List items use tighter line spacing than body text (1.2× instead of 1.5×).
// This keeps lists compact while remaining legible at 12 pt.
dinLineHtList = 5.0 // 12 pt × 1.2 ≈ 5.08 mm, rounded to 5.0
dinSpaceAfterList = 3.0 // gap inserted after the outermost list exits
)
// Appendix holds the title and image path for one annex entry.
// AppendixKind distinguishes between image and table annexes.
type AppendixKind int
const (
AppendixKindImage AppendixKind = iota
AppendixKindTable
)
// Appendix holds the content for one annex entry.
type Appendix struct {
Title string
Path string
Kind AppendixKind
Landscape bool // true → render on a landscape A4 page (15 mm symmetric margins)
Title string
Path string // image path (Kind == AppendixKindImage)
TableData [][]string // table rows (Kind == AppendixKindTable)
}
// IHKRenderer is the central PDF generator for IHK Chemnitz project documentation.
@@ -139,18 +156,63 @@ func (r *IHKRenderer) AddSource(source string) {
r.sources = append(r.sources, source)
}
// AddAppendix registers an annex entry in "Title | /path/to/image" format.
// AddAppendix registers an image annex in "Title | /path/to/image" format.
func (r *IHKRenderer) AddAppendix(titlePath string) {
parts := strings.SplitN(titlePath, "|", 2)
if len(parts) < 2 {
log.Printf("warning: @Anhang directive missing '|' separator: %q", titlePath)
return
}
r.appendices = append(r.appendices, Appendix{
Kind: AppendixKindImage,
Title: strings.TrimSpace(parts[0]),
Path: strings.TrimSpace(parts[1]),
})
}
// AddTableAppendix registers a table annex entry.
func (r *IHKRenderer) AddTableAppendix(title string, data [][]string) {
if len(data) == 0 {
log.Printf("warning: @TabelleAnhang %q has no table data — skipped", title)
return
}
r.appendices = append(r.appendices, Appendix{
Kind: AppendixKindTable,
Title: title,
TableData: data,
})
}
// AddLandscapeAppendix registers an image annex in "Title | /path/to/image" format
// that will be rendered on a landscape A4 page with 15 mm symmetric margins.
func (r *IHKRenderer) AddLandscapeAppendix(titlePath string) {
parts := strings.SplitN(titlePath, "|", 2)
if len(parts) < 2 {
log.Printf("warning: @AnhangBildQuer directive missing '|' separator: %q", titlePath)
return
}
r.appendices = append(r.appendices, Appendix{
Kind: AppendixKindImage,
Landscape: true,
Title: strings.TrimSpace(parts[0]),
Path: strings.TrimSpace(parts[1]),
})
}
// AddTableAppendixLandscape registers a table annex entry rendered on a landscape page.
func (r *IHKRenderer) AddTableAppendixLandscape(title string, data [][]string) {
if len(data) == 0 {
log.Printf("warning: @TabelleAnhangQuer %q has no table data — skipped", title)
return
}
r.appendices = append(r.appendices, Appendix{
Kind: AppendixKindTable,
Landscape: true,
Title: title,
TableData: data,
})
}
// Save writes the completed PDF to the given file path.
func (r *IHKRenderer) Save(filename string) error {
return r.pdf.OutputFileAndClose(filename)
BIN
View File
Binary file not shown.
+278 -40
View File
@@ -19,20 +19,25 @@ abbreviations:
meaning: "Abstract Syntax Tree"
- abbr: "DIN"
meaning: "Deutsches Institut für Normung"
- abbr: "YAML"
meaning: "YAML Ain't Markup Language"
glossary:
- term: "Goldmark"
definition: "Ein in Go geschriebener Markdown-Parser, der den CommonMark-Standard implementiert."
definition: "Ein in Go geschriebener Markdown-Parser, der den CommonMark-Standard implementiert und durch Erweiterungen (Tabellen, YAML-Frontmatter) ergänzt werden kann."
- term: "FPDF"
definition: "Eine Go-Bibliothek zur Erzeugung von PDF-Dokumenten ohne externe Abhängigkeiten."
definition: "Eine Go-Bibliothek zur Erzeugung von PDF-Dokumenten ohne externe Systemabhängigkeiten."
- term: "Kroki"
definition: "Ein Webdienst, der verschiedene Diagramm-Beschreibungssprachen (Mermaid, PlantUML u.a.) in Bilder umwandelt."
definition: "Ein Webdienst, der verschiedene Diagramm-Beschreibungssprachen (Mermaid, PlantUML u.a.) serverseitig in Bilder umwandelt."
- term: "Zwei-Pass-Rendering"
definition: "Verfahren, bei dem ein Dokument zweimal gerendert wird: Der erste Durchlauf ermittelt Seitenzahlen, der zweite nutzt diese für das Inhaltsverzeichnis."
---
# Vorwort
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.
Fachrichtung Anwendungsentwicklung. Es soll zeigen, dass technische
Dokumentationen effizient und normgerecht erstellt werden können ohne
manuelle Nachbearbeitung in einer Textverarbeitung.
# 1. Problemstellung
@@ -41,70 +46,303 @@ effizient und normgerecht erstellt werden können.
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.
manuelle Sorgfalt bei jedem Absatz. Änderungen am Inhalt erzwingen
regelmäßig manuelle Korrekturen an der Formatierung.
## 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:
formalen Anforderungen der IHK Chemnitz automatisch erfüllt. Das Tool soll:
- die Prüfungsvorbereitung erleichtern, die Qualität der Dokumente *einheitlich* sicherstellen unddie Qualität der Dokumente *einheitlich* sicherstellen unddie Qualität der Dokumente *einheitlich* sicherstellen unddie Qualität der Dokumente *einheitlich* sicherstellen unddie Qualität der Dokumente *einheitlich* sicherstellen unddie Qualität der Dokumente *einheitlich* sicherstellen unddie Qualität der Dokumente *einheitlich* sicherstellen unddie Qualität der Dokumente *einheitlich* sicherstellen unddie Qualität der Dokumente *einheitlich* sicherstellen unddie Qualität der Dokumente *einheitlich* sicherstellen unddie Qualität der Dokumente *einheitlich* sicherstellen unddie Qualität der Dokumente *einheitlich* sicherstellen unddie Qualität der Dokumente *einheitlich* sicherstellen unddie Qualität der Dokumente *einheitlich* sicherstellen unddie Qualität der Dokumente *einheitlich* sicherstellen und
- die Prüfungsvorbereitung erleichtern,
- die Qualität der Dokumente *einheitlich* sicherstellen und
- den Prozess vollständig automatisieren.
- den gesamten Formatierungsprozess vollständig automatisieren.
Besondere Anforderung ist die Einhaltung der **DIN 5008**-Norm sowie der
spezifischen Vorgaben der IHK Chemnitz (Korrekturrand 4 cm rechts,
Schriftart Arial/Helvetica 12 pt, 1½-zeiliger Abstand).
# 2. Projektablauf
## 2.1 Planung
## 2.1 Planung und Zeitrahmen
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
@Tabelle: Projektphasen mit Zeitplanung
| Phase | Aufgabe | Stunden |
|-------|---------|---------|
| 1 | Anforderungsanalyse und Recherche der IHK-Vorgaben sowie DIN 5008 Normen | 8 |
| 2 | Architekturentwurf und Auswahl geeigneter Go-Bibliotheken (Goldmark, FPDF) | 10 |
| 3 | Implementierung des Markdown-Parsers mit AST-Traversierung | 20 |
| 4 | Implementierung des PDF-Renderers mit allen IHK-Elementen | 24 |
| 5 | Tests, Fehlerkorrektur und Dokumentation | 12 |
| 6 | Puffer und Abnahme | 6 |
## 2.2 Architektur
Die Software ist in Go implementiert und folgt einer klaren Trennung
zwischen Parsing und Rendering. Das Zwei-Pass-Verfahren ermöglicht ein
korrektes Inhaltsverzeichnis mit Seitenzahlen.
@DiagrammQuer: Systemarchitektur des Konverters Zwei-Pass-Rendering
```mermaid
graph TD
A[Markdown] --> B(Go Parser)
B --> C{Metadaten?}
C -->|Ja| D[Config]
C -->|Nein| E[Standard]
D --> F[PDF Renderer]
E --> F
F --> G[IHK-konformes PDF]
graph LR
subgraph Eingabe
MD[report.md]
CFG[YAML Frontmatter]
end
subgraph Parsing
P(ParseMarkdown)
AST[Goldmark AST]
end
subgraph Pass1[Pass 1 - Seitenzahlen ermitteln]
R1[IHKRenderer]
TOC[tocItems mit Seitenzahlen]
end
subgraph Pass2[Pass 2 - Finales PDF]
R2[IHKRenderer]
PDF[projektarbeit.pdf]
end
MD --> P
CFG --> P
P --> AST
AST --> R1
R1 --> TOC
TOC --> R2
AST --> R2
R2 --> PDF
```
## 2.2 Realisierung
### Modulstruktur
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.
Der Konverter ist in neun fokussierte Go-Dateien aufgeteilt:
| Werkzeug | Zweck | Version |
|----------|-------|---------|
| Go | Programmiersprache | 1.22+ |
| Goldmark | Markdown-Parser (AST) | v1.8 |
| FPDF | PDF-Erzeugung | v0.9 |
| Kroki | Diagramm-Rendering | online |
- **`main.go`** Einstiegspunkt, CLI-Flags, Zwei-Pass-Pipeline
- **`config.go`** YAML-Konfigurationsstruktur
- **`markdown_parser.go`** Goldmark-AST-Traversierung
- **`pdf_renderer.go`** Kern-Struct `IHKRenderer`, DIN-5008-Konstanten
- **`pdf_content.go`** Inhalts-Renderer (Absätze, Listen, Tabellen, Code, Bilder)
- **`pdf_toc.go`** Verzeichnis-Renderer (Inhalts-, Tabellen-, Abbildungsverzeichnis)
- **`pdf_pages.go`** Seiten-Renderer (Titelseite, Anhang, Erklärung, Glossar)
- **`pdf_numbering.go`** Seitennummerierung (Römisch / Arabisch)
- **`diagram.go`** Kroki-Diagramm-Rendering mit SHA-256-Cache
## 2.3 Realisierung
Die Realisierung erfolgt in Go unter Verwendung von `goldmark` für das
Markdown-Parsing und `fpdf` für die PDF-Erzeugung.
@Tabelle: Eingesetzte Bibliotheken und Werkzeuge
| Werkzeug | Zweck | Version | Lizenz |
|----------|-------|---------|--------|
| Go | Programmiersprache und Laufzeitumgebung | 1.22+ | BSD |
| Goldmark | Markdown-Parser mit CommonMark-konformem AST | v1.8 | MIT |
| goldmark-meta | YAML-Frontmatter-Erweiterung für Goldmark | v1.1 | MIT |
| FPDF | PDF-Erzeugung ohne externe Systemabhängigkeiten | v0.9 | MIT |
| Kroki | Diagramm-Rendering für Mermaid und PlantUML | online | Apache 2.0 |
@Quelle: Goldmark Documentation, https://github.com/yuin/goldmark, 2024
@Quelle: Go-PDF/Fpdf Documentation, https://github.com/go-pdf/fpdf, 2025
## 2.3 Test und Qualitätssicherung
### Implementierungsbeispiel: Tabellen-Renderer
Das Tool wurde anhand eines Musterdokuments getestet. Folgende Kriterien
Der folgende Ausschnitt zeigt die Kernlogik des mehrzeiligen Tabellen-Renderers.
Jede Zelle wird mit `SplitLines` vorgemessen; anschließend werden alle Zellen
einer Zeile auf einheitliche Höhe gebracht:
```go
func (r *IHKRenderer) prepareRow(rawCells []string, numCols int,
colW, lineHt float64, bold bool) tableRowData {
r.pdf.SetFont("Helvetica", map[bool]string{true: "B", false: ""}[bold], dinFontCaption)
cells := make([][]string, numCols)
maxLines := 0
for j := 0; j < numCols; j++ {
raw := ""
if j < len(rawCells) {
raw = rawCells[j]
}
split := r.pdf.SplitLines([]byte(r.tr(raw)), colW-2)
lines := make([]string, len(split))
for k, b := range split {
lines[k] = string(b)
}
if len(lines) == 0 {
lines = []string{""}
}
cells[j] = lines
if len(lines) > maxLines {
maxLines = len(lines)
}
}
return tableRowData{cells: cells, height: float64(maxLines) * lineHt}
}
```
## 2.4 Test und Qualitätssicherung
Das Tool wurde anhand dieses 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
1. Korrekte Seitenränder gemäß DIN 5008 (links 3 cm, rechts 4 cm Korrekturrand)
2. Seitennummerierung römisch im Vorspann (ab II), arabisch im Textteil (ab 1)
3. Schriftart Helvetica, 12 Punkt, 1½-zeiliger Abstand (6,35 mm Zeilenhöhe)
4. Automatisch generierte Verzeichnisse (Inhalt, Tabellen, Abbildungen)
5. Mehrzeilige Tabellenzellen mit einheitlicher Zeilenhöhe pro Tabellenzeile
6. Nummerierende Code-Blöcke mit Zeilennummern-Gutter
7. Listen mit korrektem hängendem Einzug (zweite Zeile bündig mit Text)
8. Tabellen als Anhang über die Direktive `@TabelleAnhang:`
# 3. Zusammenfassung
@Tabelle: Testergebnisse der Qualitätssicherung
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.
| Kriterium | Erwartet | Ergebnis | Status |
|-----------|----------|----------|--------|
| Linker Seitenrand | 30 mm | 30 mm | Bestanden |
| Rechter Seitenrand (Korrekturrand) | 40 mm | 40 mm | Bestanden |
| Schriftgröße Fließtext | 12 pt | 12 pt | Bestanden |
| Zeilenabstand Fließtext | 6,35 mm (1,5-fach) | 6,35 mm | Bestanden |
| Römische Nummerierung Vorspann | ab Seite II | ab Seite II | Bestanden |
| Arabische Nummerierung Textteil | ab Seite 1 | ab Seite 1 | Bestanden |
| Mehrzeilige Tabellenzellen | einheitliche Zeilenhöhe | einheitliche Zeilenhöhe | Bestanden |
# 3. Verwendung der Direktiven
## 3.1 Quellenangaben und Bibliografie
Quellenangaben werden mit der Direktive `@Quelle:` eingefügt und am Ende
des Dokuments alphabetisch sortiert im Literaturverzeichnis ausgegeben.
Die Direktive kann an beliebiger Stelle im Dokument stehen.
## 3.2 Benannte Tabellen
Mit `@Tabelle: Tabellenname` unmittelbar vor einer Markdown-Tabelle wird
der Tabelle ein Name gegeben. Dieser erscheint im Tabellenverzeichnis:
```
@Tabelle: Übersicht der Programmiersprachen
| Sprache | Paradigma | Typsystem |
|---------|-----------|-----------|
| Go | Imperativ | Statisch |
| Python | Multi | Dynamisch |
```
## 3.3 Tabellen als Anhang
Mit `@TabelleAnhang: Anlagenname` wird die folgende Tabelle nicht im
Fließtext gerendert, sondern als eigene Anlage im Anhang platziert:
```
@TabelleAnhang: Vollständige Fehlerliste
| Code | Beschreibung | Schwere |
|------|--------------|---------|
| E001 | Datei nicht gefunden | Kritisch |
```
## 3.4 Diagramme als Anhang
Mit `@AnhangUML:` gefolgt von einem Mermaid- oder PlantUML-Codeblock wird
das Diagramm als Anlage eingebettet statt im Fließtext:
```
@AnhangUML: Datenbankschema
\`\`\`plantuml
@startuml
entity User { ... }
@enduml
\`\`\`
```
# 4. Zusammenfassung
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. Künftige Erweiterungen könnten Fußnoten, Querverweise und
eine konfigurierbare Spaltenbreite für Tabellen umfassen.
@Quelle: IHK Chemnitz, Hinweise zur Erarbeitung der Dokumentation über die Projektarbeit, 2020
@Quelle: DIN 5008:2020-03, Schreib- und Gestaltungsregeln für die Textverarbeitung, Beuth Verlag
@Anhang: Architektur-Diagramm Übersicht | test.png
@TabelleAnhang: Vollständige Liste der unterstützten Markdown-Direktiven
| Direktive | Syntax | Beschreibung |
|-----------|--------|--------------|
| Quelle | @Quelle: Text | Fügt einen Literaturverweis hinzu, der alphabetisch im Literaturverzeichnis erscheint |
| Tabelle | @Tabelle: Name | Gibt der folgenden Tabelle einen Namen für das Tabellenverzeichnis |
| Tabellenanhang | @TabelleAnhang: Name | Sendet die folgende Tabelle als nummerierte Anlage in den Anhang |
| Bildanhang | @Anhang: Titel \| Pfad | Fügt ein Bild als nummerierte Anlage in den Anhang ein |
| Diagrammanhang | @AnhangUML: Titel | Rendert den folgenden Mermaid/PlantUML-Block als Anlage |
@AnhangUML: Systemarchitektur Datenflussdiagramm
```mermaid
graph LR
MD[report.md] -->|ParseMarkdown| AST[Goldmark AST]
AST -->|Pass 1| R1[IHKRenderer Pass 1]
R1 -->|tocItems| R2[IHKRenderer Pass 2]
AST -->|Pass 2| R2
R2 -->|Save| OUT[projektarbeit.pdf]
```
@AnhangUMLQuer: Vollständige Modulübersicht Querformat
```mermaid
graph TD
subgraph Eingabe
MD[report.md]
CFG[YAML Frontmatter]
end
subgraph Parser
MP[markdown_parser.go]
AST[Goldmark AST]
end
subgraph Renderer
IR[pdf_renderer.go]
IC[pdf_content.go]
IT[pdf_toc.go]
IP[pdf_pages.go]
IN[pdf_numbering.go]
end
subgraph Diagramme
DG[diagram.go]
KR[Kroki API]
end
subgraph Ausgabe
PDF[projektarbeit.pdf]
end
MD --> MP
CFG --> MP
MP --> AST
AST --> IR
IR --> IC
IR --> IT
IR --> IP
IR --> IN
IC --> DG
DG --> KR
KR --> DG
IP --> PDF
```
@TabelleAnhangQuer: Vollständige Direktiven-Referenz im Querformat
| Direktive | Syntax | Beschreibung | Querformat |
|-----------|--------|--------------|------------|
| Quelle | @Quelle: Text | Literaturverweis, alphabetisch sortiert im Literaturverzeichnis | |
| Tabelle | @Tabelle: Name | Gibt der nächsten Tabelle einen Namen für das Tabellenverzeichnis | |
| Tabellenanhang | @TabelleAnhang: Name | Sendet die nächste Tabelle als Anlage in den Anhang | |
| Tabellenanhang Quer | @TabelleAnhangQuer: Name | Tabelle als Anlage im Querformat (297×210 mm, 15 mm Rand) | Ja |
| Bildanhang | @Anhang: Titel \| Pfad | Bild als nummerierte Anlage (Hochformat) | |
| Bildanhang Quer | @AnhangBildQuer: Titel \| Pfad | Bild als nummerierte Anlage im Querformat | Ja |
| Diagrammanhang | @AnhangUML: Titel | Nächster Mermaid/PlantUML-Block als Anlage (Hochformat) | |
| Diagrammanhang Quer | @AnhangUMLQuer: Titel | Nächster Mermaid/PlantUML-Block als Anlage im Querformat | Ja |
| Diagramm Querseite | @DiagrammQuer: Titel | Nächster Mermaid/PlantUML-Block inline als Querformat-Seite | |