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" /> <option name="autoReloadType" value="ALL" />
</component> </component>
<component name="ChangeListManager"> <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$/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$/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_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$/projektarbeit.pdf" beforeDir="false" afterPath="$PROJECT_DIR$/projektarbeit.pdf" afterDir="false" />
<change beforePath="$PROJECT_DIR$/report.md" beforeDir="false" afterPath="$PROJECT_DIR$/report.md" afterDir="false" /> <change beforePath="$PROJECT_DIR$/report.md" beforeDir="false" afterPath="$PROJECT_DIR$/report.md" afterDir="false" />
</list> </list>
@@ -17,7 +23,7 @@
<option name="LAST_RESOLUTION" value="IGNORE" /> <option name="LAST_RESOLUTION" value="IGNORE" />
</component> </component>
<component name="EmbeddingIndexingInfo"> <component name="EmbeddingIndexingInfo">
<option name="cachedIndexableFilesCount" value="19" /> <option name="cachedIndexableFilesCount" value="22" />
<option name="fileBasedEmbeddingIndicesEnabled" value="true" /> <option name="fileBasedEmbeddingIndicesEnabled" value="true" />
</component> </component>
<component name="GOROOT" url="file:///usr/lib/go" /> <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="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 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." /> <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>
<component name="XDebuggerManager"> <component name="XDebuggerManager">
<breakpoint-manager> <breakpoint-manager>
<breakpoints> <breakpoints>
<line-breakpoint enabled="true" type="DlvLineBreakpoint"> <line-breakpoint enabled="true" type="DlvLineBreakpoint">
<url>file://$PROJECT_DIR$/main.go</url> <url>file://$PROJECT_DIR$/main.go</url>
<line>37</line> <line>40</line>
<option name="timeStamp" value="1" /> <option name="timeStamp" value="1" />
</line-breakpoint> </line-breakpoint>
</breakpoints> </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** 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.
IT vocational exams (Verordnung 2020).
## 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 - Go 1.22 or later
grade deductions (*"kann zu Punktabzug bei der Bewertung führen"*). - 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 ## Build & Run
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.
```bash ```bash
git clone <repository-url> go build -o ihk-pdf .
cd MarkdownToIHKChemnits ./ihk-pdf -i report.md -o projektarbeit.pdf
go mod tidy
``` ```
## Usage Or without building:
```bash ```bash
go run . -i report.md -o projektarbeit.pdf go run . -i report.md -o projektarbeit.pdf
``` ```
---
## CLI Flags
| Flag | Default | Description | | Flag | Default | Description |
|---|---|---| |------|---------|-------------|
| `-i` | `report.md` | Input Markdown file | | `-i` | `report.md` | Input Markdown file |
| `-o` | `projektarbeit.pdf` | Output PDF 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 ## YAML Front Matter
Every Markdown file starts with a YAML block that drives the title page, Every input file begins with a YAML block delimited by `---`. All fields are optional except where noted.
abbreviation list, and glossary:
```yaml ```yaml
--- ---
student: student:
name: "Max Mustermann" name: "Max Mustermann" # required
profession: "Fachinformatiker Fachrichtung Anwendungsentwicklung" profession: "Fachinformatiker Fachrichtung Anwendungsentwicklung"
company: "Musterfirma GmbH" company: "Musterfirma GmbH"
supervisor: "Sabine Supervisor" supervisor: "Sabine Supervisor"
project:
title: "Title of the project"
period: "Summer 2026"
# Optional — generates Abkürzungsverzeichnis project:
abbreviations: title: "Titel der Projektarbeit" # required
subtitle: "Optionaler Untertitel" # optional
period: "Sommer 2026"
abbreviations: # optional → Abkürzungsverzeichnis
- abbr: "API" - abbr: "API"
meaning: "Application Programming Interface" meaning: "Application Programming Interface"
- abbr: "DIN" - abbr: "IHK"
meaning: "Deutsches Institut für Normung" meaning: "Industrie- und Handelskammer"
# Optional — generates Glossar glossary: # optional → Glossar (after appendices)
glossary:
- term: "Goldmark" - 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 The generated PDF follows the mandatory IHK Chemnitz order:
# Vorwort
This section uses Roman page numbers.
# 1. Problem Statement | # | Section | Page Numbering |
Any numbered or unknown heading switches to Arabic 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 ### Front-matter detection
**Bold** and *italic* text are preserved in the PDF output.
# Bibliography entries 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.
@Quelle: Author, Title, Publisher, Year
> Quelle: Alternative blockquote syntax also works
# Appendix images ---
@Anhang: Description | /path/to/image.png
# UML diagram as appendix (rendered via Kroki) ## DIN 5008 / IHK Formatting Rules
@AnhangUML: Sequence Diagram Title
` ``puml | 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 @startuml
A -> B: request entity User {
+ id : int
+ name : string
}
@enduml @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 ` ``mermaid
graph TD 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/ @DiagrammQuer: Systemarchitektur Zwei-Pass-Rendering
├── main.go Entry point; two-pass rendering pipeline ` ``mermaid
├── config.go Config struct (student, project, abbreviations, glossary) graph LR
MD[report.md] --> Parser --> AST --> Renderer --> PDF
├── 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
``` ```
---
## 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 ## License
MIT MIT
+17 -9
View File
@@ -10,15 +10,22 @@ import (
"net/http" "net/http"
"os" "os"
"path/filepath" "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.) // RenderDiagramViaKroki converts a diagram source (Mermaid, PlantUML, etc.)
// to a PNG image using the Kroki.io public rendering service and caches the // to a PNG image via a Kroki rendering service and caches the result in the
// result in the OS temp directory. // OS temp directory. The base URL is controlled by krokiBaseURL (-kroki flag).
//
// The cache key is the SHA-256 hash of the diagram source, so unchanged
// diagrams are not re-fetched between runs.
// //
// Cache key: SHA-256 of the diagram source — unchanged diagrams are not re-fetched.
// Supported languages: "mermaid", "plantuml" / "puml" // Supported languages: "mermaid", "plantuml" / "puml"
func RenderDiagramViaKroki(lang, code string) (string, error) { func RenderDiagramViaKroki(lang, code string) (string, error) {
if lang == "puml" { if lang == "puml" {
@@ -35,7 +42,7 @@ func RenderDiagramViaKroki(lang, code string) (string, error) {
return "", fmt.Errorf("zlib close: %w", err) return "", fmt.Errorf("zlib close: %w", err)
} }
encoded := base64.URLEncoding.EncodeToString(buf.Bytes()) 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 // Deterministic cache path based on content hash
hash := fmt.Sprintf("%x", sha256.Sum256([]byte(code))) hash := fmt.Sprintf("%x", sha256.Sum256([]byte(code)))
@@ -45,14 +52,14 @@ func RenderDiagramViaKroki(lang, code string) (string, error) {
return cachePath, nil // cache hit 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 { 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() defer resp.Body.Close()
if resp.StatusCode != http.StatusOK { 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) out, err := os.Create(cachePath)
@@ -62,6 +69,7 @@ func RenderDiagramViaKroki(lang, code string) (string, error) {
defer out.Close() defer out.Close()
if _, err = io.Copy(out, resp.Body); err != nil { 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 "", fmt.Errorf("cache file write: %w", err)
} }
return cachePath, nil 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() { func main() {
inputMd := flag.String("i", "report.md", "Input Markdown file") inputMd := flag.String("i", "report.md", "Input Markdown file")
outputPdf := flag.String("o", "projektarbeit.pdf", "Output PDF 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() flag.Parse()
krokiBaseURL = *kroki
config, doc, content, err := ParseMarkdown(*inputMd) config, doc, content, err := ParseMarkdown(*inputMd)
if err != nil { if err != nil {
log.Fatalf("Failed to parse input: %v", err) log.Fatalf("Failed to parse input: %v", err)
+74 -9
View File
@@ -3,6 +3,7 @@ package main
import ( import (
"bytes" "bytes"
"fmt" "fmt"
"log"
"os" "os"
"strings" "strings"
@@ -43,9 +44,15 @@ func ParseMarkdown(mdPath string) (Config, ast.Node, []byte, error) {
// parserState tracks transient state during the AST walk. // parserState tracks transient state during the AST walk.
type parserState struct { type parserState struct {
nextCodeIsAppendix bool nextCodeIsAppendix bool
appendixTitle string nextAppendixLandscape bool // set by @AnhangUMLQuer: — landscape for diagram appendix
listStack []listFrame // stack for nested list tracking 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. // 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" { if lang == "mermaid" || lang == "plantuml" || lang == "puml" {
imgPath, err := RenderDiagramViaKroki(lang, code) 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 { if err == nil {
caption := lang switch {
if state.nextCodeIsAppendix { case state.nextDiagramLandscape:
r.AddAppendix(state.appendixTitle + " | " + imgPath) 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 state.nextCodeIsAppendix = false
} else { default:
r.RenderImage(imgPath, "Diagram ("+caption+")") r.RenderImage(imgPath, "Diagram ("+lang+")")
} }
return ast.WalkSkipChildren, nil return ast.WalkSkipChildren, nil
} }
@@ -162,6 +185,11 @@ func RenderAST(doc ast.Node, content []byte, r *IHKRenderer) error {
if len(state.listStack) > 0 { if len(state.listStack) > 0 {
state.listStack = state.listStack[:len(state.listStack)-1] 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 return ast.WalkContinue, nil
@@ -192,7 +220,20 @@ func RenderAST(doc ast.Node, content []byte, r *IHKRenderer) error {
} }
tableData = append(tableData, rowData) 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 return ast.WalkSkipChildren, nil
} }
@@ -226,10 +267,34 @@ func handleDirectives(text string, state *parserState, r *IHKRenderer) bool {
case strings.HasPrefix(line, "@Anhang:"): case strings.HasPrefix(line, "@Anhang:"):
r.AddAppendix(strings.TrimSpace(strings.TrimPrefix(line, "@Anhang:"))) r.AddAppendix(strings.TrimSpace(strings.TrimPrefix(line, "@Anhang:")))
handled = true 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:"): case strings.HasPrefix(line, "@AnhangUML:"):
state.nextCodeIsAppendix = true state.nextCodeIsAppendix = true
state.appendixTitle = strings.TrimSpace(strings.TrimPrefix(line, "@AnhangUML:")) state.appendixTitle = strings.TrimSpace(strings.TrimPrefix(line, "@AnhangUML:"))
handled = true 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 return handled
+169 -56
View File
@@ -2,6 +2,7 @@ package main
import ( import (
"fmt" "fmt"
"log"
"strconv" "strconv"
"strings" "strings"
@@ -111,7 +112,7 @@ func (r *IHKRenderer) RenderListItem(spans []InlineSpan, ordered bool, index, in
// Render bullet/number; CellFormat advances X to textX automatically. // Render bullet/number; CellFormat advances X to textX automatically.
r.pdf.SetX(lm + indentMM) 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 hasFormatting := false
for _, s := range spans { for _, s := range spans {
@@ -126,7 +127,7 @@ func (r *IHKRenderer) RenderListItem(spans []InlineSpan, ordered bool, index, in
for _, s := range spans { for _, s := range spans {
text += s.Text text += s.Text
} }
r.pdf.MultiCell(textW, dinLineHtBody, r.tr(text), "", "L", false) r.pdf.MultiCell(textW, dinLineHtList, r.tr(text), "", "L", false)
} else { } else {
for _, span := range spans { for _, span := range spans {
style := fontStyle(span.Bold, span.Italic) style := fontStyle(span.Bold, span.Italic)
@@ -135,70 +136,149 @@ func (r *IHKRenderer) RenderListItem(spans []InlineSpan, ordered bool, index, in
} else { } else {
r.pdf.SetFont("Helvetica", style, dinFontBody) 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 // tableRowData holds pre-computed line-wrapped content for one table row.
// a auto-repeat header on page breaks. Each table is numbered and recorded type tableRowData struct {
// in the Tabellenverzeichnis per IHK section 2.4. cells [][]string // wrapped lines per cell
// height float64 // total row height in mm
// data[0] is the header row; data[1:] are body rows. }
// 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) { func (r *IHKRenderer) RenderTable(data [][]string, caption string) {
if len(data) == 0 { if len(data) == 0 {
return return
} }
r.tableCount++ r.tableCount++
label := fmt.Sprintf("Tab. %d", r.tableCount) label := fmt.Sprintf("Tab. %d", r.tableCount)
if caption != "" { if caption != "" {
label += ": " + caption label += ": " + caption
} }
r.RecordTable(label) 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] header := data[0]
numCols := len(header) numCols := len(header)
uw := r.usableWidth() if numCols == 0 {
colW := uw / float64(numCols) return
// renderHeaderRow draws the (grey) header row, optionally with a "(Fortsetzung)" suffix.
renderHeaderRow := func(continued bool) {
title := label
if continued {
title += " (Fortsetzung)"
}
r.pdf.SetFont("Helvetica", "B", dinFontCaption)
r.pdf.CellFormat(0, dinLineHtCaption+2, r.tr(title), "", 1, "L", false, 0, "")
r.pdf.SetFillColor(230, 230, 230)
r.pdf.SetFont("Helvetica", "B", dinFontCaption)
for _, col := range header {
r.pdf.CellFormat(colW, dinLineHtCaption+2, r.tr(col), "1", 0, "C", true, 0, "")
}
r.pdf.Ln(-1)
} }
renderHeaderRow(false) uw := r.usableWidth()
colW := uw / float64(numCols)
r.pdf.SetFont("Helvetica", "", dinFontCaption) lineHt := dinLineHtCaption + 2
r.pdf.SetFillColor(255, 255, 255)
_, pageH := r.pdf.GetPageSize() _, pageH := r.pdf.GetPageSize()
_, _, _, bm := r.pdf.GetMargins() _, _, _, 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++ { 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() r.pdf.AddPage()
renderHeaderRow(true) renderHeader(true)
r.pdf.SetFont("Helvetica", "", dinFontCaption)
} }
for _, col := range data[i] { r.drawRow(row, numCols, colW, lineHt, false)
r.pdf.CellFormat(colW, rowH, r.tr(col), "1", 0, "L", false, 0, "")
}
r.pdf.Ln(-1)
} }
r.pdf.Ln(dinSpaceAfterParagraph) r.pdf.Ln(dinSpaceAfterParagraph)
} }
@@ -212,6 +292,7 @@ func (r *IHKRenderer) RenderImage(path string, caption string) {
info := r.ensureImageRegistered(path) info := r.ensureImageRegistered(path)
if info == nil { if info == nil {
log.Printf("warning: image not found, rendering placeholder: %q", path)
r.pdf.SetFont("Helvetica", "I", dinFontCaption) r.pdf.SetFont("Helvetica", "I", dinFontCaption)
r.pdf.CellFormat(0, dinLineHtCaption, r.tr("[Bild nicht gefunden: "+path+"]"), r.pdf.CellFormat(0, dinLineHtCaption, r.tr("[Bild nicht gefunden: "+path+"]"),
"1", 1, "C", false, 0, "") "1", 1, "C", false, 0, "")
@@ -283,11 +364,13 @@ const (
// │ 1 │ package main │ // │ 1 │ package main │
// │ 2 │ │ // │ 2 │ │
// │ 3 │ func main() { … } │ // │ 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. // 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) { func (r *IHKRenderer) RenderCodeBlock(lang, code string) {
lines := strings.Split(strings.TrimRight(code, "\n"), "\n") lines := strings.Split(strings.TrimRight(code, "\n"), "\n")
if len(lines) == 0 { if len(lines) == 0 {
@@ -311,22 +394,33 @@ func (r *IHKRenderer) RenderCodeBlock(lang, code string) {
r.pdf.SetFont("Courier", "", codeFont) 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 { for i, line := range lines {
// Start a new page if this line would fall below the bottom margin. chunks := wrapCodeLine(line, maxChars)
if r.pdf.GetY()+codeLineHt > pageH-bm { for ci, chunk := range chunks {
r.pdf.AddPage() 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. // Reset colours so subsequent content is unaffected.
@@ -334,6 +428,25 @@ func (r *IHKRenderer) RenderCodeBlock(lang, code string) {
r.pdf.Ln(dinSpaceAfterParagraph) 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. // fontStyle returns the fpdf font style string for a given bold/italic combination.
func fontStyle(bold, italic bool) string { func fontStyle(bold, italic bool) string {
switch { switch {
+108 -4
View File
@@ -2,6 +2,7 @@ package main
import ( import (
"fmt" "fmt"
"log"
"sort" "sort"
"github.com/go-pdf/fpdf" "github.com/go-pdf/fpdf"
@@ -126,8 +127,8 @@ func (r *IHKRenderer) RenderBibliography() {
} }
// RenderAppendices renders the appendix section followed by individual annex // RenderAppendices renders the appendix section followed by individual annex
// pages. The opening page contains the index of all annexes. Each annex image // pages. The opening page contains the index of all annexes. Annexes marked
// is scaled to fill the available page area. // Landscape=true are placed on a landscape A4 page with 15 mm symmetric margins.
func (r *IHKRenderer) RenderAppendices() { func (r *IHKRenderer) RenderAppendices() {
if len(r.appendices) == 0 { if len(r.appendices) == 0 {
return return
@@ -144,14 +145,41 @@ func (r *IHKRenderer) RenderAppendices() {
r.tr(fmt.Sprintf("Anlage %d: %s", i+1, app.Title)), "", 1, "L", false, 0, "") 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 // Individual annex pages
for i, app := range r.appendices { 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.SetFont("Helvetica", "B", dinFontBody)
r.pdf.CellFormat(0, dinLineHtBody, r.pdf.CellFormat(0, dinLineHtBody,
r.tr(fmt.Sprintf("Anlage %d: %s", i+1, app.Title)), "", 1, "L", false, 0, "") r.tr(fmt.Sprintf("Anlage %d: %s", i+1, app.Title)), "", 1, "L", false, 0, "")
r.pdf.Ln(dinSpaceAfterHeading) 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 // ensureImageRegistered registers an image with fpdf if not already known
// and returns its metadata. Returns nil if the image cannot be loaded. // and returns its metadata. Returns nil if the image cannot be loaded.
func (r *IHKRenderer) ensureImageRegistered(path string) *fpdf.ImageInfoType { 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}) r.pdf.RegisterImageOptions(path, fpdf.ImageOptions{ReadDpi: true})
info = r.pdf.GetImageInfo(path) info = r.pdf.GetImageInfo(path)
} }
if info == nil {
log.Printf("warning: could not load image %q", path)
}
return info return info
} }
+66 -4
View File
@@ -4,6 +4,7 @@
package main package main
import ( import (
"log"
"strconv" "strconv"
"strings" "strings"
@@ -36,12 +37,28 @@ const (
dinSpaceBeforeHeading = 2 * dinLineHtBody // ≈ 12.7 mm dinSpaceBeforeHeading = 2 * dinLineHtBody // ≈ 12.7 mm
dinSpaceAfterHeading = dinLineHtBody // ≈ 6.35 mm dinSpaceAfterHeading = dinLineHtBody // ≈ 6.35 mm
dinSpaceAfterParagraph = 4.0 // between body paragraphs 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 { type Appendix struct {
Title string Kind AppendixKind
Path string 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. // 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) 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) { func (r *IHKRenderer) AddAppendix(titlePath string) {
parts := strings.SplitN(titlePath, "|", 2) parts := strings.SplitN(titlePath, "|", 2)
if len(parts) < 2 { if len(parts) < 2 {
log.Printf("warning: @Anhang directive missing '|' separator: %q", titlePath)
return return
} }
r.appendices = append(r.appendices, Appendix{ r.appendices = append(r.appendices, Appendix{
Kind: AppendixKindImage,
Title: strings.TrimSpace(parts[0]), Title: strings.TrimSpace(parts[0]),
Path: strings.TrimSpace(parts[1]), 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. // Save writes the completed PDF to the given file path.
func (r *IHKRenderer) Save(filename string) error { func (r *IHKRenderer) Save(filename string) error {
return r.pdf.OutputFileAndClose(filename) 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" meaning: "Abstract Syntax Tree"
- abbr: "DIN" - abbr: "DIN"
meaning: "Deutsches Institut für Normung" meaning: "Deutsches Institut für Normung"
- abbr: "YAML"
meaning: "YAML Ain't Markup Language"
glossary: glossary:
- term: "Goldmark" - 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" - 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" - 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 # Vorwort
Dieses Projekt entstand im Rahmen der Abschlussprüfung zum Fachinformatiker Dieses Projekt entstand im Rahmen der Abschlussprüfung zum Fachinformatiker
Fachrichtung Anwendungsentwicklung. Es soll zeigen, dass technische Dokumentationen Fachrichtung Anwendungsentwicklung. Es soll zeigen, dass technische
effizient und normgerecht erstellt werden können. Dokumentationen effizient und normgerecht erstellt werden können ohne
manuelle Nachbearbeitung in einer Textverarbeitung.
# 1. Problemstellung # 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 Aktuell müssen IHK-Dokumentationen mühsam in Word formatiert werden, was
fehleranfällig ist und viel Zeit kostet. Besonders die Einhaltung der fehleranfällig ist und viel Zeit kostet. Besonders die Einhaltung der
Formvorgaben Schriftgröße, Zeilenabstand und Seitenränder erfordert 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 ## 1.2 Zielsetzung
Ziel ist ein Go-Tool, das **Markdown** in PDF umwandelt und dabei alle 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 - 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. Projektablauf
## 2.1 Planung ## 2.1 Planung und Zeitrahmen
Die Planung umfasst die Analyse der IHK-Vorgaben und das Design der Die Planung umfasst die Analyse der IHK-Vorgaben und das Design der
Software-Architektur. Der Projektzeitraum beträgt maximal 80 Stunden Software-Architektur. Der Projektzeitraum beträgt maximal 80 Stunden
gemäß Ausbildungsverordnung. 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 ```mermaid
graph TD graph LR
A[Markdown] --> B(Go Parser) subgraph Eingabe
B --> C{Metadaten?} MD[report.md]
C -->|Ja| D[Config] CFG[YAML Frontmatter]
C -->|Nein| E[Standard] end
D --> F[PDF Renderer] subgraph Parsing
E --> F P(ParseMarkdown)
F --> G[IHK-konformes PDF] 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 ist in neun fokussierte Go-Dateien aufgeteilt:
Der Konverter verarbeitet die Markdown-Datei in zwei Durchläufen, um das
Inhaltsverzeichnis korrekt mit Seitenangaben zu befüllen.
| Werkzeug | Zweck | Version | - **`main.go`** Einstiegspunkt, CLI-Flags, Zwei-Pass-Pipeline
|----------|-------|---------| - **`config.go`** YAML-Konfigurationsstruktur
| Go | Programmiersprache | 1.22+ | - **`markdown_parser.go`** Goldmark-AST-Traversierung
| Goldmark | Markdown-Parser (AST) | v1.8 | - **`pdf_renderer.go`** Kern-Struct `IHKRenderer`, DIN-5008-Konstanten
| FPDF | PDF-Erzeugung | v0.9 | - **`pdf_content.go`** Inhalts-Renderer (Absätze, Listen, Tabellen, Code, Bilder)
| Kroki | Diagramm-Rendering | online | - **`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: Goldmark Documentation, https://github.com/yuin/goldmark, 2024
@Quelle: Go-PDF/Fpdf Documentation, https://github.com/go-pdf/fpdf, 2025 @Quelle: Go-PDF/Fpdf Documentation, https://github.com/go-pdf/fpdf, 2025
## 2.3 Test und Qualitätssicherung ### 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: wurden geprüft:
1. Korrekte Seitenränder gemäß DIN 5008 1. Korrekte Seitenränder gemäß DIN 5008 (links 3 cm, rechts 4 cm Korrekturrand)
2. Seitennummerierung (römisch im Vorspann, arabisch im Textteil) 2. Seitennummerierung römisch im Vorspann (ab II), arabisch im Textteil (ab 1)
3. Schriftart Helvetica, 12 Punkt, 1½-zeilig 3. Schriftart Helvetica, 12 Punkt, 1½-zeiliger Abstand (6,35 mm Zeilenhöhe)
4. Automatische Abbildungs- und Tabellenverzeichnisse 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 | Kriterium | Erwartet | Ergebnis | Status |
Einhaltung **aller** Formatvorgaben. Durch die Trennung von Inhalt (Markdown) |-----------|----------|----------|--------|
und Formatierung (Go-Renderer) ist eine konsistente Ausgabe garantiert. | 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: 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 | |