diff --git a/.idea/workspace.xml b/.idea/workspace.xml
index b34a452..eb77c5d 100644
--- a/.idea/workspace.xml
+++ b/.idea/workspace.xml
@@ -4,10 +4,16 @@
-
+
+
+
+
+
+
+
@@ -17,7 +23,7 @@
-
+
@@ -95,14 +101,16 @@
-
+
+
+
file://$PROJECT_DIR$/main.go
- 37
+ 40
diff --git a/MarkdownToIHKChemnits b/MarkdownToIHKChemnits
index a097859..4275ddd 100755
Binary files a/MarkdownToIHKChemnits and b/MarkdownToIHKChemnits differ
diff --git a/README.md b/README.md
index c9a0fac..4a7a56a 100644
--- a/README.md
+++ b/README.md
@@ -1,156 +1,345 @@
-# Markdown → IHK 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 A–Z)
-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
-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
diff --git a/diagram.go b/diagram.go
index 2b1b20d..1d9cf26 100644
--- a/diagram.go
+++ b/diagram.go
@@ -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
diff --git a/docker-compose.yml b/docker-compose.yml
new file mode 100644
index 0000000..3ffa0bd
--- /dev/null
+++ b/docker-compose.yml
@@ -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"
diff --git a/main.go b/main.go
index 6870bd9..d70754e 100644
--- a/main.go
+++ b/main.go
@@ -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)
diff --git a/markdown_parser.go b/markdown_parser.go
index f20ac04..2b3f032 100644
--- a/markdown_parser.go
+++ b/markdown_parser.go
@@ -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
diff --git a/pdf_content.go b/pdf_content.go
index 9e07bc4..7dddadd 100644
--- a/pdf_content.go
+++ b/pdf_content.go
@@ -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 {
diff --git a/pdf_pages.go b/pdf_pages.go
index a2dcbcd..4c49373 100644
--- a/pdf_pages.go
+++ b/pdf_pages.go
@@ -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
}
diff --git a/pdf_renderer.go b/pdf_renderer.go
index 081d444..45b5d19 100644
--- a/pdf_renderer.go
+++ b/pdf_renderer.go
@@ -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)
diff --git a/projektarbeit.pdf b/projektarbeit.pdf
index ab0bdae..8076355 100644
Binary files a/projektarbeit.pdf and b/projektarbeit.pdf differ
diff --git a/report.md b/report.md
index e6290ad..dd3e333 100644
--- a/report.md
+++ b/report.md
@@ -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 | – |