Remove outdated toc_pages.txt, add new Go modules for IHK Chemnitz PDF rendering including diagrams, tables, and TOC functionality.
This commit is contained in:
Generated
+4
@@ -0,0 +1,4 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<module version="4">
|
||||||
|
<component name="Go" enabled="true" />
|
||||||
|
</module>
|
||||||
Generated
+11
@@ -0,0 +1,11 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<project version="4">
|
||||||
|
<component name="GoImports">
|
||||||
|
<option name="excludedPackages">
|
||||||
|
<array>
|
||||||
|
<option value="github.com/pkg/errors" />
|
||||||
|
<option value="golang.org/x/net/context" />
|
||||||
|
</array>
|
||||||
|
</option>
|
||||||
|
</component>
|
||||||
|
</project>
|
||||||
Generated
+6
@@ -0,0 +1,6 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<project version="4">
|
||||||
|
<component name="VcsDirectoryMappings">
|
||||||
|
<mapping directory="$PROJECT_DIR$" vcs="Git" />
|
||||||
|
</component>
|
||||||
|
</project>
|
||||||
Generated
+117
@@ -0,0 +1,117 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<project version="4">
|
||||||
|
<component name="AutoImportSettings">
|
||||||
|
<option name="autoReloadType" value="ALL" />
|
||||||
|
</component>
|
||||||
|
<component name="ChangeListManager">
|
||||||
|
<list default="true" id="c64f46d5-641a-468c-8fc1-94edec1f2deb" name="Changes" comment="Initial commit: added Markdown to IHK Chemnitz PDF converter with core structure and features, including YAML config, Goldmark parser, and PDF renderer.">
|
||||||
|
<change afterPath="$PROJECT_DIR$/it-berufe-handreichung-vo2020-data(1).pdf" afterDir="false" />
|
||||||
|
<change beforePath="$PROJECT_DIR$/README.md" beforeDir="false" afterPath="$PROJECT_DIR$/README.md" afterDir="false" />
|
||||||
|
<change beforePath="$PROJECT_DIR$/config.go" beforeDir="false" afterPath="$PROJECT_DIR$/config.go" afterDir="false" />
|
||||||
|
<change beforePath="$PROJECT_DIR$/img-000.png" beforeDir="false" />
|
||||||
|
<change beforePath="$PROJECT_DIR$/img-001.png" beforeDir="false" />
|
||||||
|
<change beforePath="$PROJECT_DIR$/img-002.png" beforeDir="false" />
|
||||||
|
<change beforePath="$PROJECT_DIR$/img-003.png" beforeDir="false" />
|
||||||
|
<change beforePath="$PROJECT_DIR$/main.go" beforeDir="false" afterPath="$PROJECT_DIR$/main.go" afterDir="false" />
|
||||||
|
<change beforePath="$PROJECT_DIR$/markdown_parser.go" beforeDir="false" afterPath="$PROJECT_DIR$/markdown_parser.go" afterDir="false" />
|
||||||
|
<change beforePath="$PROJECT_DIR$/pdf_renderer.go" beforeDir="false" afterPath="$PROJECT_DIR$/pdf_renderer.go" afterDir="false" />
|
||||||
|
<change beforePath="$PROJECT_DIR$/projektarbeit.pdf" beforeDir="false" afterPath="$PROJECT_DIR$/projektarbeit.pdf" afterDir="false" />
|
||||||
|
<change beforePath="$PROJECT_DIR$/report.md" beforeDir="false" afterPath="$PROJECT_DIR$/report.md" afterDir="false" />
|
||||||
|
<change beforePath="$PROJECT_DIR$/toc_pages.txt" beforeDir="false" />
|
||||||
|
</list>
|
||||||
|
<option name="SHOW_DIALOG" value="false" />
|
||||||
|
<option name="HIGHLIGHT_CONFLICTS" value="true" />
|
||||||
|
<option name="HIGHLIGHT_NON_ACTIVE_CHANGELIST" value="false" />
|
||||||
|
<option name="LAST_RESOLUTION" value="IGNORE" />
|
||||||
|
</component>
|
||||||
|
<component name="EmbeddingIndexingInfo">
|
||||||
|
<option name="cachedIndexableFilesCount" value="19" />
|
||||||
|
<option name="fileBasedEmbeddingIndicesEnabled" value="true" />
|
||||||
|
</component>
|
||||||
|
<component name="GOROOT" url="file:///usr/lib/go" />
|
||||||
|
<component name="Git.Settings">
|
||||||
|
<option name="RECENT_GIT_ROOT_PATH" value="$PROJECT_DIR$" />
|
||||||
|
</component>
|
||||||
|
<component name="ProjectColorInfo">{
|
||||||
|
"associatedIndex": 5
|
||||||
|
}</component>
|
||||||
|
<component name="ProjectId" id="3BO2O12K0JCiteftC8DGGgmYYyZ" />
|
||||||
|
<component name="ProjectViewState">
|
||||||
|
<option name="hideEmptyMiddlePackages" value="true" />
|
||||||
|
<option name="showLibraryContents" value="true" />
|
||||||
|
</component>
|
||||||
|
<component name="PropertiesComponent">{
|
||||||
|
"keyToString": {
|
||||||
|
"ModuleVcsDetector.initialDetectionPerformed": "true",
|
||||||
|
"RunOnceActivity.GoLinterPluginOnboardingV2": "true",
|
||||||
|
"RunOnceActivity.GoLinterPluginStorageMigration": "true",
|
||||||
|
"RunOnceActivity.ShowReadmeOnStart": "true",
|
||||||
|
"RunOnceActivity.TerminalTabsStorage.copyFrom.TerminalArrangementManager.252": "true",
|
||||||
|
"RunOnceActivity.git.unshallow": "true",
|
||||||
|
"RunOnceActivity.go.formatter.settings.were.checked": "true",
|
||||||
|
"RunOnceActivity.go.modules.go.list.on.any.changes.was.set": "true",
|
||||||
|
"RunOnceActivity.typescript.service.memoryLimit.init": "true",
|
||||||
|
"com.intellij.ml.llm.matterhorn.ej.ui.settings.DefaultModelSelectionForGA.v1": "true",
|
||||||
|
"git-widget-placeholder": "main",
|
||||||
|
"junie.onboarding.icon.badge.shown": "true",
|
||||||
|
"last_opened_file_path": "/home/sebastianu/GolandProjects/MarkdownToIHKChemnits",
|
||||||
|
"node.js.detected.package.eslint": "true",
|
||||||
|
"node.js.selected.package.eslint": "(autodetect)",
|
||||||
|
"nodejs_package_manager_path": "npm",
|
||||||
|
"onboarding.tips.debug.path": "/home/sebastianu/GolandProjects/MarkdownToIHKChemnits/main.go",
|
||||||
|
"to.speed.mode.migration.done": "true"
|
||||||
|
}
|
||||||
|
}</component>
|
||||||
|
<component name="RecentsManager">
|
||||||
|
<key name="CopyFile.RECENT_KEYS">
|
||||||
|
<recent name="$PROJECT_DIR$" />
|
||||||
|
</key>
|
||||||
|
</component>
|
||||||
|
<component name="SharedIndexes">
|
||||||
|
<attachedChunks>
|
||||||
|
<set>
|
||||||
|
<option value="bundled-gosdk-72a9cf600ed8-448e06e64ec5-org.jetbrains.plugins.go.sharedIndexes.bundled-GO-253.28294.337" />
|
||||||
|
<option value="bundled-js-predefined-d6986cc7102b-c7e53b3be11b-JavaScript-GO-253.28294.337" />
|
||||||
|
</set>
|
||||||
|
</attachedChunks>
|
||||||
|
</component>
|
||||||
|
<component name="TaskManager">
|
||||||
|
<task active="true" id="Default" summary="Default task">
|
||||||
|
<changelist id="c64f46d5-641a-468c-8fc1-94edec1f2deb" name="Changes" comment="" />
|
||||||
|
<created>1774345760438</created>
|
||||||
|
<option name="number" value="Default" />
|
||||||
|
<option name="presentableId" value="Default" />
|
||||||
|
<updated>1774345760438</updated>
|
||||||
|
</task>
|
||||||
|
<servers />
|
||||||
|
</component>
|
||||||
|
<component name="TypeScriptGeneratedFilesManager">
|
||||||
|
<option name="version" value="3" />
|
||||||
|
</component>
|
||||||
|
<component name="Vcs.Log.Tabs.Properties">
|
||||||
|
<option name="TAB_STATES">
|
||||||
|
<map>
|
||||||
|
<entry key="MAIN">
|
||||||
|
<value>
|
||||||
|
<State />
|
||||||
|
</value>
|
||||||
|
</entry>
|
||||||
|
</map>
|
||||||
|
</option>
|
||||||
|
</component>
|
||||||
|
<component name="VcsManagerConfiguration">
|
||||||
|
<MESSAGE value="Initial commit: added Markdown to IHK Chemnitz PDF converter with core structure and features, including YAML config, Goldmark parser, and PDF renderer." />
|
||||||
|
<option name="LAST_COMMIT_MESSAGE" value="Initial commit: added Markdown to IHK Chemnitz PDF converter with core structure and features, including YAML config, Goldmark parser, and PDF renderer." />
|
||||||
|
</component>
|
||||||
|
<component name="XDebuggerManager">
|
||||||
|
<breakpoint-manager>
|
||||||
|
<breakpoints>
|
||||||
|
<line-breakpoint enabled="true" type="DlvLineBreakpoint">
|
||||||
|
<url>file://$PROJECT_DIR$/main.go</url>
|
||||||
|
<line>37</line>
|
||||||
|
<option name="timeStamp" value="1" />
|
||||||
|
</line-breakpoint>
|
||||||
|
</breakpoints>
|
||||||
|
</breakpoint-manager>
|
||||||
|
</component>
|
||||||
|
</project>
|
||||||
Executable
BIN
Binary file not shown.
@@ -1,47 +1,156 @@
|
|||||||
# Markdown to IHK Chemnitz PDF Converter
|
# Markdown → IHK Chemnitz PDF Converter
|
||||||
|
|
||||||
Dieses Tool konvertiert Markdown-Dateien in professionelle Projektdokumentationen, die den formalen Anforderungen der **IHK Chemnitz** (IT-Berufe, Stand 2020/2026) entsprechen.
|
Converts Markdown files into compliant project documentation for **IHK Chemnitz**
|
||||||
|
IT vocational exams (Verordnung 2020).
|
||||||
|
|
||||||
## Features
|
---
|
||||||
|
|
||||||
- **IHK-konforme Formatierung**: Automatische Einhaltung von Seitenrändern (30mm links, 40mm Korrekturrand rechts), Schriftarten und Zeilenabständen.
|
## IHK Formatting Requirements
|
||||||
- **Paging in Tabellen**:
|
|
||||||
- **Tabellen-Pagination**: Automatisches Wiederholen der Kopfzeile auf Folgeseiten.
|
All rules below are enforced automatically. Deviating from them can lead to
|
||||||
- **Fortsetzungs-Markierung**: Kennzeichnung von umgebrochenen Tabellen mit "(Fortsetzung)".
|
grade deductions (*"kann zu Punktabzug bei der Bewertung führen"*).
|
||||||
- **Tabellen-Fußzeile**: Seitennummerierung und Projektdaten in einer sauberen Tabellenstruktur am Seitenende.
|
|
||||||
- **Diagramme via Kroki**: Unterstützung für Mermaid, PlantUML und andere Formate direkt im Markdown.
|
| Rule | Value | Note |
|
||||||
- **Automatisierte Verzeichnisse**:
|
|---|---|---|
|
||||||
- Inhaltsverzeichnis mit korrekter Paginierung (römisch/arabisch).
|
| Font | Helvetica / Arial, black | PDF-safe equivalent |
|
||||||
- Literaturverzeichnis (sortiert).
|
| Body size | 12 pt | |
|
||||||
- Anlagenverzeichnis.
|
| Heading size | 14 pt, bold | |
|
||||||
- **Metadaten**: Einfache Konfiguration von Schüler- und Projektdaten via YAML-Frontmatter.
|
| 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
|
## Installation
|
||||||
|
|
||||||
Stellen Sie sicher, dass [Go](https://golang.org/) installiert ist.
|
Requires [Go](https://golang.org/) 1.22 or newer.
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
git clone https://github.com/ihk-markdown-renderer
|
git clone <repository-url>
|
||||||
cd MarkdownToIHKChemnits
|
cd MarkdownToIHKChemnits
|
||||||
go mod tidy
|
go mod tidy
|
||||||
```
|
```
|
||||||
|
|
||||||
## Benutzung
|
## Usage
|
||||||
|
|
||||||
1. Erstellen Sie eine `report.md` mit YAML-Frontmatter (siehe Beispiel).
|
|
||||||
2. Führen Sie den Konverter aus:
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
go run . -i report.md -o projektarbeit.pdf
|
go run . -i report.md -o projektarbeit.pdf
|
||||||
```
|
```
|
||||||
|
|
||||||
## Projektstruktur
|
| Flag | Default | Description |
|
||||||
|
|---|---|---|
|
||||||
|
| `-i` | `report.md` | Input Markdown file |
|
||||||
|
| `-o` | `projektarbeit.pdf` | Output PDF file |
|
||||||
|
|
||||||
- `main.go`: Einstiegspunkt und Orchestrierung.
|
## YAML Front Matter
|
||||||
- `markdown_parser.go`: AST-Parsing und Integration der Goldmark-Extensions.
|
|
||||||
- `pdf_renderer.go`: FPDF-Logik für IHK-Layout, Tabellen-Paging und Fußzeilen.
|
|
||||||
- `config.go`: Konfigurationsstrukturen.
|
|
||||||
|
|
||||||
## Lizenz
|
Every Markdown file starts with a YAML block that drives the title page,
|
||||||
|
abbreviation list, and glossary:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
---
|
||||||
|
student:
|
||||||
|
name: "Max Mustermann"
|
||||||
|
profession: "Fachinformatiker Fachrichtung Anwendungsentwicklung"
|
||||||
|
company: "Musterfirma GmbH"
|
||||||
|
supervisor: "Sabine Supervisor"
|
||||||
|
project:
|
||||||
|
title: "Title of the project"
|
||||||
|
period: "Summer 2026"
|
||||||
|
|
||||||
|
# Optional — generates Abkürzungsverzeichnis
|
||||||
|
abbreviations:
|
||||||
|
- abbr: "API"
|
||||||
|
meaning: "Application Programming Interface"
|
||||||
|
- abbr: "DIN"
|
||||||
|
meaning: "Deutsches Institut für Normung"
|
||||||
|
|
||||||
|
# Optional — generates Glossar
|
||||||
|
glossary:
|
||||||
|
- term: "Goldmark"
|
||||||
|
definition: "A CommonMark-compliant Markdown parser written in Go."
|
||||||
|
---
|
||||||
|
```
|
||||||
|
|
||||||
|
## Markdown Directives
|
||||||
|
|
||||||
|
Special directives inside paragraphs control bibliography, appendix, and diagrams:
|
||||||
|
|
||||||
|
```markdown
|
||||||
|
# Vorwort
|
||||||
|
This section uses Roman page numbers.
|
||||||
|
|
||||||
|
# 1. Problem Statement
|
||||||
|
Any numbered or unknown heading switches to Arabic numbering.
|
||||||
|
|
||||||
|
# Inline formatting
|
||||||
|
**Bold** and *italic* text are preserved in the PDF output.
|
||||||
|
|
||||||
|
# Bibliography entries
|
||||||
|
@Quelle: Author, Title, Publisher, Year
|
||||||
|
> Quelle: Alternative blockquote syntax also works
|
||||||
|
|
||||||
|
# Appendix images
|
||||||
|
@Anhang: Description | /path/to/image.png
|
||||||
|
|
||||||
|
# UML diagram as appendix (rendered via Kroki)
|
||||||
|
@AnhangUML: Sequence Diagram Title
|
||||||
|
` ``puml
|
||||||
|
@startuml
|
||||||
|
A -> B: request
|
||||||
|
@enduml
|
||||||
|
` ``
|
||||||
|
|
||||||
|
# Inline diagrams (rendered via Kroki, embedded in body)
|
||||||
|
` ``mermaid
|
||||||
|
graph TD
|
||||||
|
A --> B
|
||||||
|
` ``
|
||||||
|
```
|
||||||
|
|
||||||
|
Diagrams are rendered via [Kroki.io](https://kroki.io) and cached locally
|
||||||
|
using the SHA-256 hash of the diagram source as the cache key.
|
||||||
|
|
||||||
|
## Project Structure
|
||||||
|
|
||||||
|
```
|
||||||
|
MarkdownToIHKChemnits/
|
||||||
|
│
|
||||||
|
├── main.go Entry point; two-pass rendering pipeline
|
||||||
|
├── config.go Config struct (student, project, abbreviations, glossary)
|
||||||
|
│
|
||||||
|
├── markdown_parser.go Goldmark AST walker; inline span extraction; directives
|
||||||
|
├── diagram.go Kroki.io integration (Mermaid, PlantUML, …)
|
||||||
|
│
|
||||||
|
├── pdf_renderer.go IHKRenderer struct; DIN 5008 constants; core helpers
|
||||||
|
├── pdf_numbering.go NumberingType enum; toRoman()
|
||||||
|
├── pdf_toc.go Table of contents; list of tables; list of figures
|
||||||
|
├── pdf_pages.go Title, declaration, bibliography, appendix, abbrev., glossary
|
||||||
|
└── pdf_content.go Headings, paragraphs, tables, images, list items
|
||||||
|
```
|
||||||
|
|
||||||
|
## License
|
||||||
|
|
||||||
MIT
|
MIT
|
||||||
|
|||||||
@@ -1,5 +1,24 @@
|
|||||||
package main
|
package main
|
||||||
|
|
||||||
|
// Config holds all document metadata from the Markdown YAML front matter.
|
||||||
|
// It drives the title page, abbreviation list, and glossary.
|
||||||
|
//
|
||||||
|
// Example front matter:
|
||||||
|
//
|
||||||
|
// student:
|
||||||
|
// name: "Max Mustermann"
|
||||||
|
// profession: "Fachinformatiker Fachrichtung Anwendungsentwicklung"
|
||||||
|
// company: "Musterfirma GmbH"
|
||||||
|
// supervisor: "Sabine Supervisor"
|
||||||
|
// project:
|
||||||
|
// title: "Thema der Projektarbeit"
|
||||||
|
// period: "Sommer 2026"
|
||||||
|
// abbreviations:
|
||||||
|
// - abbr: "API"
|
||||||
|
// meaning: "Application Programming Interface"
|
||||||
|
// glossary:
|
||||||
|
// - term: "Agile"
|
||||||
|
// definition: "Iteratives Vorgehensmodell der Softwareentwicklung"
|
||||||
type Config struct {
|
type Config struct {
|
||||||
Student struct {
|
Student struct {
|
||||||
Name string `yaml:"name"`
|
Name string `yaml:"name"`
|
||||||
@@ -7,9 +26,28 @@ type Config struct {
|
|||||||
Company string `yaml:"company"`
|
Company string `yaml:"company"`
|
||||||
Supervisor string `yaml:"supervisor"`
|
Supervisor string `yaml:"supervisor"`
|
||||||
} `yaml:"student"`
|
} `yaml:"student"`
|
||||||
|
|
||||||
Project struct {
|
Project struct {
|
||||||
Title string `yaml:"title"`
|
Title string `yaml:"title"`
|
||||||
Period string `yaml:"period"`
|
Period string `yaml:"period"`
|
||||||
Subtitle string `yaml:"subtitle"`
|
Subtitle string `yaml:"subtitle"`
|
||||||
} `yaml:"project"`
|
} `yaml:"project"`
|
||||||
|
|
||||||
|
// Abbreviations populates the list of abbreviations placed after the TOC.
|
||||||
|
Abbreviations []Abbreviation `yaml:"abbreviations"`
|
||||||
|
|
||||||
|
// Glossary populates the Glossar section placed before the declaration page.
|
||||||
|
Glossary []GlossaryEntry `yaml:"glossary"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Abbreviation is one entry in the list of abbreviations.
|
||||||
|
type Abbreviation struct {
|
||||||
|
Abbr string `yaml:"abbr"`
|
||||||
|
Meaning string `yaml:"meaning"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// GlossaryEntry is one entry in the Glossar section.
|
||||||
|
type GlossaryEntry struct {
|
||||||
|
Term string `yaml:"term"`
|
||||||
|
Definition string `yaml:"definition"`
|
||||||
}
|
}
|
||||||
|
|||||||
+68
@@ -0,0 +1,68 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"compress/zlib"
|
||||||
|
"crypto/sha256"
|
||||||
|
"encoding/base64"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
)
|
||||||
|
|
||||||
|
// RenderDiagramViaKroki converts a diagram source (Mermaid, PlantUML, etc.)
|
||||||
|
// to a PNG image using the Kroki.io public rendering service and caches the
|
||||||
|
// result in the OS temp directory.
|
||||||
|
//
|
||||||
|
// The cache key is the SHA-256 hash of the diagram source, so unchanged
|
||||||
|
// diagrams are not re-fetched between runs.
|
||||||
|
//
|
||||||
|
// Supported languages: "mermaid", "plantuml" / "puml"
|
||||||
|
func RenderDiagramViaKroki(lang, code string) (string, error) {
|
||||||
|
if lang == "puml" {
|
||||||
|
lang = "plantuml"
|
||||||
|
}
|
||||||
|
|
||||||
|
// Kroki encoding: deflate (zlib) then base64url
|
||||||
|
var buf bytes.Buffer
|
||||||
|
w := zlib.NewWriter(&buf)
|
||||||
|
if _, err := w.Write([]byte(code)); err != nil {
|
||||||
|
return "", fmt.Errorf("zlib write: %w", err)
|
||||||
|
}
|
||||||
|
if err := w.Close(); err != nil {
|
||||||
|
return "", fmt.Errorf("zlib close: %w", err)
|
||||||
|
}
|
||||||
|
encoded := base64.URLEncoding.EncodeToString(buf.Bytes())
|
||||||
|
url := fmt.Sprintf("https://kroki.io/%s/png/%s", lang, encoded)
|
||||||
|
|
||||||
|
// Deterministic cache path based on content hash
|
||||||
|
hash := fmt.Sprintf("%x", sha256.Sum256([]byte(code)))
|
||||||
|
cachePath := filepath.Join(os.TempDir(), "ihk_kroki_"+hash+".png")
|
||||||
|
|
||||||
|
if _, err := os.Stat(cachePath); err == nil {
|
||||||
|
return cachePath, nil // cache hit
|
||||||
|
}
|
||||||
|
|
||||||
|
resp, err := http.Get(url) //nolint:gosec // URL is constructed from user content, acceptable here
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("kroki request: %w", err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
return "", fmt.Errorf("kroki returned HTTP %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
out, err := os.Create(cachePath)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("cache file create: %w", err)
|
||||||
|
}
|
||||||
|
defer out.Close()
|
||||||
|
|
||||||
|
if _, err = io.Copy(out, resp.Body); err != nil {
|
||||||
|
return "", fmt.Errorf("cache file write: %w", err)
|
||||||
|
}
|
||||||
|
return cachePath, nil
|
||||||
|
}
|
||||||
BIN
Binary file not shown.
|
Before Width: | Height: | Size: 52 KiB |
BIN
Binary file not shown.
|
Before Width: | Height: | Size: 3.7 KiB |
BIN
Binary file not shown.
|
Before Width: | Height: | Size: 3.6 KiB |
BIN
Binary file not shown.
|
Before Width: | Height: | Size: 3.7 KiB |
Binary file not shown.
@@ -4,6 +4,8 @@ import (
|
|||||||
"flag"
|
"flag"
|
||||||
"fmt"
|
"fmt"
|
||||||
"log"
|
"log"
|
||||||
|
|
||||||
|
"github.com/yuin/goldmark/ast"
|
||||||
)
|
)
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
@@ -13,49 +15,59 @@ func main() {
|
|||||||
|
|
||||||
config, doc, content, err := ParseMarkdown(*inputMd)
|
config, doc, content, err := ParseMarkdown(*inputMd)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Fatalf("Error parsing markdown: %v", err)
|
log.Fatalf("Failed to parse input: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Pass 1: Dummy render to collect TOC info
|
// Pass 1 — dummy render to collect page numbers for the TOC, tables, and figures.
|
||||||
dummyRenderer := NewIHKRenderer(config)
|
// The recorded page numbers are used to fill the TOC in pass 2.
|
||||||
dummyRenderer.RenderTitlePage()
|
pass1 := NewIHKRenderer(config)
|
||||||
dummyRenderer.RenderTOC() // This is the placeholder
|
if err = renderPipeline(pass1, doc, content); err != nil {
|
||||||
dummyRenderer.pageOffset = dummyRenderer.pdf.PageNo()
|
log.Fatalf("Pass 1 failed: %v", err)
|
||||||
RenderAST(doc, content, dummyRenderer)
|
|
||||||
dummyRenderer.RenderBibliography()
|
|
||||||
dummyRenderer.RenderListOfTables()
|
|
||||||
dummyRenderer.RenderAppendices()
|
|
||||||
|
|
||||||
// Pass 2: Real render
|
|
||||||
renderer := NewIHKRenderer(config)
|
|
||||||
renderer.tocItems = dummyRenderer.tocItems
|
|
||||||
renderer.tableItems = dummyRenderer.tableItems
|
|
||||||
|
|
||||||
renderer.RenderTitlePage()
|
|
||||||
renderer.RenderTOC()
|
|
||||||
|
|
||||||
// Main Content
|
|
||||||
err = RenderAST(doc, content, renderer)
|
|
||||||
if err != nil {
|
|
||||||
log.Fatalf("Error rendering PDF: %v", err)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 4. Bibliography
|
// Pass 2 — final render with the TOC index collected in pass 1.
|
||||||
renderer.RenderBibliography()
|
// Only tocItems must be pre-seeded because RenderTOC() runs before RenderAST().
|
||||||
|
// tableItems and figureItems are re-populated during RenderAST() in pass 2,
|
||||||
// List of Tables
|
// so they must NOT be copied here (that would produce duplicates in the lists).
|
||||||
renderer.RenderListOfTables()
|
pass2 := NewIHKRenderer(config)
|
||||||
|
pass2.tocItems = pass1.tocItems
|
||||||
// 5. Appendices
|
if err = renderPipeline(pass2, doc, content); err != nil {
|
||||||
renderer.RenderAppendices()
|
log.Fatalf("Pass 2 failed: %v", err)
|
||||||
|
|
||||||
// 6. Declaration of Authenticity
|
|
||||||
renderer.RenderDeclarationPage()
|
|
||||||
|
|
||||||
err = renderer.Save(*outputPdf)
|
|
||||||
if err != nil {
|
|
||||||
log.Fatalf("Error saving PDF: %v", err)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fmt.Printf("Successfully generated %s\n", *outputPdf)
|
if err = pass2.Save(*outputPdf); err != nil {
|
||||||
|
log.Fatalf("Failed to save PDF: %v", err)
|
||||||
|
}
|
||||||
|
fmt.Printf("PDF created: %s\n", *outputPdf)
|
||||||
|
}
|
||||||
|
|
||||||
|
// renderPipeline executes the full document rendering sequence in the mandatory
|
||||||
|
// IHK Chemnitz order:
|
||||||
|
//
|
||||||
|
// 1. Title page (no page number)
|
||||||
|
// 2. Table of contents (Roman II)
|
||||||
|
// 3. List of abbrev. (Roman, from YAML — optional)
|
||||||
|
// 4. Body via AST walk (foreword Roman → main body Arabic from 1)
|
||||||
|
// 5. Bibliography (Arabic, A–Z sorted)
|
||||||
|
// 6. List of tables (Arabic)
|
||||||
|
// 7. List of figures (Arabic)
|
||||||
|
// 8. Appendix / annex (Arabic)
|
||||||
|
// 9. Glossary (Arabic, from YAML — optional)
|
||||||
|
// 10. Declaration (no page number)
|
||||||
|
func renderPipeline(r *IHKRenderer, doc ast.Node, content []byte) error {
|
||||||
|
r.RenderTitlePage()
|
||||||
|
r.RenderTOC()
|
||||||
|
r.RenderAbbreviations()
|
||||||
|
|
||||||
|
if err := RenderAST(doc, content, r); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
r.RenderBibliography()
|
||||||
|
r.RenderListOfTables()
|
||||||
|
r.RenderListOfFigures()
|
||||||
|
r.RenderAppendices()
|
||||||
|
r.RenderGlossary()
|
||||||
|
r.RenderDeclarationPage()
|
||||||
|
return nil
|
||||||
}
|
}
|
||||||
|
|||||||
+211
-115
@@ -2,25 +2,22 @@ package main
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
"compress/zlib"
|
|
||||||
"crypto/sha256"
|
|
||||||
"encoding/base64"
|
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"strings"
|
||||||
|
|
||||||
"github.com/yuin/goldmark"
|
"github.com/yuin/goldmark"
|
||||||
"github.com/yuin/goldmark-meta"
|
meta "github.com/yuin/goldmark-meta"
|
||||||
"github.com/yuin/goldmark/ast"
|
"github.com/yuin/goldmark/ast"
|
||||||
"github.com/yuin/goldmark/extension"
|
"github.com/yuin/goldmark/extension"
|
||||||
extast "github.com/yuin/goldmark/extension/ast"
|
extast "github.com/yuin/goldmark/extension/ast"
|
||||||
"github.com/yuin/goldmark/parser"
|
"github.com/yuin/goldmark/parser"
|
||||||
"github.com/yuin/goldmark/text"
|
"github.com/yuin/goldmark/text"
|
||||||
"gopkg.in/yaml.v3"
|
"gopkg.in/yaml.v3"
|
||||||
"io"
|
|
||||||
"net/http"
|
|
||||||
"os"
|
|
||||||
"path/filepath"
|
|
||||||
"strings"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// ParseMarkdown reads a Markdown file, extracts the YAML front matter into a
|
||||||
|
// Config and returns the parsed AST together with the raw source bytes.
|
||||||
func ParseMarkdown(mdPath string) (Config, ast.Node, []byte, error) {
|
func ParseMarkdown(mdPath string) (Config, ast.Node, []byte, error) {
|
||||||
content, err := os.ReadFile(mdPath)
|
content, err := os.ReadFile(mdPath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -31,146 +28,287 @@ func ParseMarkdown(mdPath string) (Config, ast.Node, []byte, error) {
|
|||||||
goldmark.WithExtensions(meta.Meta, extension.Table),
|
goldmark.WithExtensions(meta.Meta, extension.Table),
|
||||||
)
|
)
|
||||||
|
|
||||||
context := parser.NewContext()
|
ctx := parser.NewContext()
|
||||||
doc := md.Parser().Parse(text.NewReader(content), parser.WithContext(context))
|
doc := md.Parser().Parse(text.NewReader(content), parser.WithContext(ctx))
|
||||||
|
|
||||||
metaData := meta.Get(context)
|
metaData := meta.Get(ctx)
|
||||||
|
|
||||||
// Convert metaData map to Config struct
|
|
||||||
var config Config
|
var config Config
|
||||||
yamlData, _ := yaml.Marshal(metaData)
|
raw, _ := yaml.Marshal(metaData)
|
||||||
err = yaml.Unmarshal(yamlData, &config)
|
if err = yaml.Unmarshal(raw, &config); err != nil {
|
||||||
if err != nil {
|
return Config{}, nil, nil, fmt.Errorf("YAML front matter: %w", err)
|
||||||
return Config{}, nil, nil, fmt.Errorf("error parsing metadata: %v", err)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return config, doc, content, nil
|
return config, doc, content, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// parserState tracks transient state during the AST walk.
|
||||||
type parserState struct {
|
type parserState struct {
|
||||||
nextCodeIsAppendix bool
|
nextCodeIsAppendix bool
|
||||||
appendixTitle string
|
appendixTitle string
|
||||||
|
listStack []listFrame // stack for nested list tracking
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// listFrame tracks the type and item counter for one list nesting level.
|
||||||
|
type listFrame struct {
|
||||||
|
ordered bool
|
||||||
|
index int
|
||||||
|
}
|
||||||
|
|
||||||
|
// RenderAST walks the Goldmark AST and dispatches to IHKRenderer methods.
|
||||||
|
//
|
||||||
|
// Front-matter detection: a level-1 heading that is not a numbered section
|
||||||
|
// and has a name in the front-matter list ("Vorwort", "Abkürzungsverzeichnis")
|
||||||
|
// stays in Roman-numeral territory. All other level-1 headings trigger
|
||||||
|
// StartMainBody() and switch to Arabic page numbering.
|
||||||
func RenderAST(doc ast.Node, content []byte, r *IHKRenderer) error {
|
func RenderAST(doc ast.Node, content []byte, r *IHKRenderer) error {
|
||||||
r.StartFrontMatter()
|
r.StartFrontMatter()
|
||||||
state := &parserState{}
|
state := &parserState{}
|
||||||
|
|
||||||
return ast.Walk(doc, func(n ast.Node, entering bool) (ast.WalkStatus, error) {
|
return ast.Walk(doc, func(n ast.Node, entering bool) (ast.WalkStatus, error) {
|
||||||
|
switch node := n.(type) {
|
||||||
|
|
||||||
|
// ── Headings ──────────────────────────────────────────────────────────
|
||||||
|
case *ast.Heading:
|
||||||
if !entering {
|
if !entering {
|
||||||
return ast.WalkContinue, nil
|
return ast.WalkContinue, nil
|
||||||
}
|
}
|
||||||
|
title := extractPlainText(node, content)
|
||||||
switch node := n.(type) {
|
|
||||||
case *ast.Heading:
|
|
||||||
if node.Level == 1 && r.numType == NumRoman {
|
if node.Level == 1 && r.numType == NumRoman {
|
||||||
title := extractText(node, content)
|
if !isFrontMatterSection(title) {
|
||||||
if title != "Vorwort" && title != "Abkürzungsverzeichnis" {
|
|
||||||
r.StartMainBody()
|
r.StartMainBody()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
title := extractText(node, content)
|
|
||||||
r.RenderHeader(node.Level, title)
|
r.RenderHeader(node.Level, title)
|
||||||
return ast.WalkSkipChildren, nil
|
return ast.WalkSkipChildren, nil
|
||||||
|
|
||||||
|
// ── Paragraphs ────────────────────────────────────────────────────────
|
||||||
case *ast.Paragraph:
|
case *ast.Paragraph:
|
||||||
text := extractText(node, content)
|
if !entering {
|
||||||
lines := strings.Split(text, "\n")
|
return ast.WalkContinue, nil
|
||||||
isMeta := false
|
|
||||||
for _, line := range lines {
|
|
||||||
line = strings.TrimSpace(line)
|
|
||||||
if strings.HasPrefix(line, "@Quelle:") {
|
|
||||||
r.AddSource(strings.TrimPrefix(line, "@Quelle:"))
|
|
||||||
isMeta = true
|
|
||||||
} else if strings.HasPrefix(line, "@Anhang:") {
|
|
||||||
r.AddAppendix(strings.TrimPrefix(line, "@Anhang:"))
|
|
||||||
isMeta = true
|
|
||||||
} else if strings.HasPrefix(line, "@AnhangUML:") {
|
|
||||||
state.nextCodeIsAppendix = true
|
|
||||||
state.appendixTitle = strings.TrimSpace(strings.TrimPrefix(line, "@AnhangUML:"))
|
|
||||||
isMeta = true
|
|
||||||
}
|
}
|
||||||
}
|
plain := extractPlainText(node, content)
|
||||||
if isMeta {
|
|
||||||
|
// Special directives embedded in paragraphs
|
||||||
|
if handled := handleDirectives(plain, state, r); handled {
|
||||||
return ast.WalkSkipChildren, nil
|
return ast.WalkSkipChildren, nil
|
||||||
}
|
}
|
||||||
r.RenderParagraph(text)
|
|
||||||
|
spans := extractInlineSpans(node, content)
|
||||||
|
r.RenderParagraphSpans(spans)
|
||||||
return ast.WalkSkipChildren, nil
|
return ast.WalkSkipChildren, nil
|
||||||
|
|
||||||
|
// ── Fenced code blocks ────────────────────────────────────────────────
|
||||||
case *ast.FencedCodeBlock:
|
case *ast.FencedCodeBlock:
|
||||||
|
if !entering {
|
||||||
|
return ast.WalkContinue, nil
|
||||||
|
}
|
||||||
lang := string(node.Language(content))
|
lang := string(node.Language(content))
|
||||||
code := extractCode(node, content)
|
code := extractCodeBlock(node, content)
|
||||||
|
|
||||||
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 {
|
if err == nil {
|
||||||
|
caption := lang
|
||||||
if state.nextCodeIsAppendix {
|
if state.nextCodeIsAppendix {
|
||||||
r.AddAppendix(state.appendixTitle + " | " + imgPath)
|
r.AddAppendix(state.appendixTitle + " | " + imgPath)
|
||||||
state.nextCodeIsAppendix = false
|
state.nextCodeIsAppendix = false
|
||||||
} else {
|
} else {
|
||||||
r.RenderImage(imgPath, "Diagramm: "+lang)
|
r.RenderImage(imgPath, "Diagram ("+caption+")")
|
||||||
}
|
}
|
||||||
return ast.WalkSkipChildren, nil
|
return ast.WalkSkipChildren, nil
|
||||||
}
|
}
|
||||||
|
// Fall through: render as plain code block on error
|
||||||
}
|
}
|
||||||
|
// Render non-diagram code blocks as monospace paragraphs
|
||||||
|
r.RenderParagraphSpans([]InlineSpan{{Text: code, Code: true}})
|
||||||
|
return ast.WalkSkipChildren, nil
|
||||||
|
|
||||||
|
// ── Images ────────────────────────────────────────────────────────────
|
||||||
case *ast.Image:
|
case *ast.Image:
|
||||||
|
if !entering {
|
||||||
|
return ast.WalkContinue, nil
|
||||||
|
}
|
||||||
imgPath := string(node.Destination)
|
imgPath := string(node.Destination)
|
||||||
title := string(node.Title)
|
caption := extractPlainText(node, content)
|
||||||
r.RenderImage(imgPath, title)
|
if caption == "" {
|
||||||
|
caption = string(node.Title)
|
||||||
|
}
|
||||||
|
r.RenderImage(imgPath, caption)
|
||||||
return ast.WalkSkipChildren, nil
|
return ast.WalkSkipChildren, nil
|
||||||
|
|
||||||
|
// ── Block quotes (alternative @Quelle syntax) ─────────────────────────
|
||||||
case *ast.Blockquote:
|
case *ast.Blockquote:
|
||||||
// Check if first paragraph starts with "Quelle:"
|
if !entering {
|
||||||
|
return ast.WalkContinue, nil
|
||||||
|
}
|
||||||
first := node.FirstChild()
|
first := node.FirstChild()
|
||||||
if first != nil {
|
if first != nil {
|
||||||
if para, ok := first.(*ast.Paragraph); ok {
|
if para, ok := first.(*ast.Paragraph); ok {
|
||||||
pText := extractText(para, content)
|
pText := extractPlainText(para, content)
|
||||||
if strings.HasPrefix(pText, "Quelle:") || strings.HasPrefix(pText, "Source:") {
|
if strings.HasPrefix(pText, "Quelle:") || strings.HasPrefix(pText, "Source:") {
|
||||||
sourceText := strings.TrimPrefix(pText, "Quelle:")
|
src := strings.TrimPrefix(strings.TrimPrefix(pText, "Quelle:"), "Source:")
|
||||||
sourceText = strings.TrimPrefix(sourceText, "Source:")
|
r.AddSource(src)
|
||||||
r.AddSource(sourceText)
|
|
||||||
return ast.WalkSkipChildren, nil
|
return ast.WalkSkipChildren, nil
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
return ast.WalkContinue, nil
|
||||||
|
|
||||||
|
// ── Lists ─────────────────────────────────────────────────────────────
|
||||||
case *ast.List:
|
case *ast.List:
|
||||||
// Items will be handled by ListItem
|
if entering {
|
||||||
|
state.listStack = append(state.listStack, listFrame{ordered: node.IsOrdered()})
|
||||||
|
} else {
|
||||||
|
if len(state.listStack) > 0 {
|
||||||
|
state.listStack = state.listStack[:len(state.listStack)-1]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ast.WalkContinue, nil
|
||||||
|
|
||||||
case *ast.ListItem:
|
case *ast.ListItem:
|
||||||
text := extractText(node, content)
|
if !entering {
|
||||||
r.RenderListItem(text, true, 0) // Basic bullet point for now
|
return ast.WalkContinue, nil
|
||||||
|
}
|
||||||
|
depth := len(state.listStack) - 1
|
||||||
|
if depth < 0 {
|
||||||
|
depth = 0
|
||||||
|
}
|
||||||
|
frame := &state.listStack[depth]
|
||||||
|
frame.index++
|
||||||
|
spans := extractInlineSpansFromListItem(node, content)
|
||||||
|
r.RenderListItem(spans, frame.ordered, frame.index, depth)
|
||||||
return ast.WalkSkipChildren, nil
|
return ast.WalkSkipChildren, nil
|
||||||
|
|
||||||
|
// ── Tables ────────────────────────────────────────────────────────────
|
||||||
case *extast.Table:
|
case *extast.Table:
|
||||||
|
if !entering {
|
||||||
|
return ast.WalkContinue, nil
|
||||||
|
}
|
||||||
var tableData [][]string
|
var tableData [][]string
|
||||||
for row := node.FirstChild(); row != nil; row = row.NextSibling() {
|
for row := node.FirstChild(); row != nil; row = row.NextSibling() {
|
||||||
var rowData []string
|
var rowData []string
|
||||||
for cell := row.FirstChild(); cell != nil; cell = cell.NextSibling() {
|
for cell := row.FirstChild(); cell != nil; cell = cell.NextSibling() {
|
||||||
rowData = append(rowData, extractText(cell, content))
|
rowData = append(rowData, extractPlainText(cell, content))
|
||||||
}
|
}
|
||||||
tableData = append(tableData, rowData)
|
tableData = append(tableData, rowData)
|
||||||
}
|
}
|
||||||
r.RenderTable(tableData)
|
r.RenderTable(tableData, "")
|
||||||
return ast.WalkSkipChildren, nil
|
return ast.WalkSkipChildren, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
return ast.WalkContinue, nil
|
return ast.WalkContinue, nil
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
func extractText(n ast.Node, content []byte) string {
|
// isFrontMatterSection returns true for level-1 headings that belong to the
|
||||||
var textStr string
|
// Roman-numbered front matter (before the main body begins).
|
||||||
for child := n.FirstChild(); child != nil; child = child.NextSibling() {
|
func isFrontMatterSection(title string) bool {
|
||||||
if textNode, ok := child.(*ast.Text); ok {
|
switch strings.TrimSpace(title) {
|
||||||
textStr += string(textNode.Segment.Value(content))
|
case "Vorwort", "Einleitung", "Abkürzungsverzeichnis":
|
||||||
if textNode.HardLineBreak() || textNode.SoftLineBreak() {
|
return true
|
||||||
textStr += "\n"
|
|
||||||
}
|
}
|
||||||
} else {
|
// Everything else (including numbered sections like "1. Problem Statement")
|
||||||
textStr += extractText(child, content)
|
// belongs to the Arabic-numbered main body.
|
||||||
}
|
return false
|
||||||
}
|
|
||||||
if textStr == "" {
|
|
||||||
// Fallback for simple nodes
|
|
||||||
return string(n.Text(content))
|
|
||||||
}
|
|
||||||
return textStr
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func extractCode(n *ast.FencedCodeBlock, content []byte) string {
|
// handleDirectives processes special @-prefixed control lines in a paragraph.
|
||||||
|
// Returns true if the paragraph was fully consumed as a directive.
|
||||||
|
func handleDirectives(text string, state *parserState, r *IHKRenderer) bool {
|
||||||
|
lines := strings.Split(text, "\n")
|
||||||
|
handled := false
|
||||||
|
for _, line := range lines {
|
||||||
|
line = strings.TrimSpace(line)
|
||||||
|
switch {
|
||||||
|
case strings.HasPrefix(line, "@Quelle:"):
|
||||||
|
r.AddSource(strings.TrimSpace(strings.TrimPrefix(line, "@Quelle:")))
|
||||||
|
handled = true
|
||||||
|
case strings.HasPrefix(line, "@Anhang:"):
|
||||||
|
r.AddAppendix(strings.TrimSpace(strings.TrimPrefix(line, "@Anhang:")))
|
||||||
|
handled = true
|
||||||
|
case strings.HasPrefix(line, "@AnhangUML:"):
|
||||||
|
state.nextCodeIsAppendix = true
|
||||||
|
state.appendixTitle = strings.TrimSpace(strings.TrimPrefix(line, "@AnhangUML:"))
|
||||||
|
handled = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return handled
|
||||||
|
}
|
||||||
|
|
||||||
|
// extractPlainText returns the plain-text content of an AST node by
|
||||||
|
// recursively concatenating all text leaf nodes.
|
||||||
|
func extractPlainText(n ast.Node, content []byte) string {
|
||||||
|
var sb strings.Builder
|
||||||
|
for child := n.FirstChild(); child != nil; child = child.NextSibling() {
|
||||||
|
if t, ok := child.(*ast.Text); ok {
|
||||||
|
sb.Write(t.Segment.Value(content))
|
||||||
|
if t.SoftLineBreak() || t.HardLineBreak() {
|
||||||
|
sb.WriteByte('\n')
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
sb.WriteString(extractPlainText(child, content))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if sb.Len() == 0 {
|
||||||
|
return string(n.Text(content))
|
||||||
|
}
|
||||||
|
return sb.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
// extractInlineSpans walks the children of a paragraph node and builds a
|
||||||
|
// slice of InlineSpan values that preserve bold, italic, and code formatting.
|
||||||
|
func extractInlineSpans(n ast.Node, content []byte) []InlineSpan {
|
||||||
|
var spans []InlineSpan
|
||||||
|
walkInline(n, content, false, false, false, &spans)
|
||||||
|
return spans
|
||||||
|
}
|
||||||
|
|
||||||
|
// extractInlineSpansFromListItem extracts spans from the first paragraph child
|
||||||
|
// of a list item, which is how Goldmark represents list item content.
|
||||||
|
func extractInlineSpansFromListItem(item *ast.ListItem, content []byte) []InlineSpan {
|
||||||
|
for child := item.FirstChild(); child != nil; child = child.NextSibling() {
|
||||||
|
if _, ok := child.(*ast.Paragraph); ok {
|
||||||
|
return extractInlineSpans(child, content)
|
||||||
|
}
|
||||||
|
// TextBlock is used for tight lists
|
||||||
|
return extractInlineSpans(child, content)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// walkInline recursively collects InlineSpan values from an AST subtree,
|
||||||
|
// propagating bold/italic context down through Emphasis nodes.
|
||||||
|
func walkInline(n ast.Node, content []byte, bold, italic, code bool, out *[]InlineSpan) {
|
||||||
|
for child := n.FirstChild(); child != nil; child = child.NextSibling() {
|
||||||
|
switch c := child.(type) {
|
||||||
|
case *ast.Text:
|
||||||
|
seg := string(c.Segment.Value(content))
|
||||||
|
if c.SoftLineBreak() {
|
||||||
|
seg += " "
|
||||||
|
} else if c.HardLineBreak() {
|
||||||
|
seg += "\n"
|
||||||
|
}
|
||||||
|
if seg != "" {
|
||||||
|
*out = append(*out, InlineSpan{Text: seg, Bold: bold, Italic: italic, Code: code})
|
||||||
|
}
|
||||||
|
case *ast.Emphasis:
|
||||||
|
childBold := bold || c.Level == 2
|
||||||
|
childItalic := italic || c.Level == 1
|
||||||
|
walkInline(c, content, childBold, childItalic, code, out)
|
||||||
|
case *ast.CodeSpan:
|
||||||
|
raw := string(c.Text(content))
|
||||||
|
*out = append(*out, InlineSpan{Text: raw, Bold: bold, Italic: italic, Code: true})
|
||||||
|
case *ast.Link:
|
||||||
|
// Render link text; the href is not shown (no footnote support yet).
|
||||||
|
walkInline(c, content, bold, italic, code, out)
|
||||||
|
default:
|
||||||
|
walkInline(c, content, bold, italic, code, out)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// extractCodeBlock returns the raw source text of a fenced code block.
|
||||||
|
func extractCodeBlock(n *ast.FencedCodeBlock, content []byte) string {
|
||||||
var buf bytes.Buffer
|
var buf bytes.Buffer
|
||||||
for i := 0; i < n.Lines().Len(); i++ {
|
for i := 0; i < n.Lines().Len(); i++ {
|
||||||
line := n.Lines().At(i)
|
line := n.Lines().At(i)
|
||||||
@@ -178,45 +316,3 @@ func extractCode(n *ast.FencedCodeBlock, content []byte) string {
|
|||||||
}
|
}
|
||||||
return buf.String()
|
return buf.String()
|
||||||
}
|
}
|
||||||
|
|
||||||
func RenderDiagramViaKroki(lang string, code string) (string, error) {
|
|
||||||
if lang == "puml" {
|
|
||||||
lang = "plantuml"
|
|
||||||
}
|
|
||||||
|
|
||||||
// Kroki encoding: zlib + base64url
|
|
||||||
var b bytes.Buffer
|
|
||||||
w := zlib.NewWriter(&b)
|
|
||||||
w.Write([]byte(code))
|
|
||||||
w.Close()
|
|
||||||
|
|
||||||
encoded := base64.URLEncoding.EncodeToString(b.Bytes())
|
|
||||||
url := fmt.Sprintf("https://kroki.io/%s/png/%s", lang, encoded)
|
|
||||||
|
|
||||||
// Cache based on hash
|
|
||||||
hash := fmt.Sprintf("%x", sha256.Sum256([]byte(code)))
|
|
||||||
cachePath := filepath.Join(os.TempDir(), "ihk_cache_"+hash+".png")
|
|
||||||
|
|
||||||
if _, err := os.Stat(cachePath); err == nil {
|
|
||||||
return cachePath, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
resp, err := http.Get(url)
|
|
||||||
if err != nil {
|
|
||||||
return "", err
|
|
||||||
}
|
|
||||||
defer resp.Body.Close()
|
|
||||||
|
|
||||||
if resp.StatusCode != 200 {
|
|
||||||
return "", fmt.Errorf("kroki error: %d", resp.StatusCode)
|
|
||||||
}
|
|
||||||
|
|
||||||
out, err := os.Create(cachePath)
|
|
||||||
if err != nil {
|
|
||||||
return "", err
|
|
||||||
}
|
|
||||||
defer out.Close()
|
|
||||||
|
|
||||||
_, err = io.Copy(out, resp.Body)
|
|
||||||
return cachePath, err
|
|
||||||
}
|
|
||||||
|
|||||||
+269
@@ -0,0 +1,269 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"strconv"
|
||||||
|
|
||||||
|
"github.com/go-pdf/fpdf"
|
||||||
|
)
|
||||||
|
|
||||||
|
// InlineSpan represents a run of text with consistent inline formatting.
|
||||||
|
// Used to pass parsed inline Markdown nodes from the parser to the renderer.
|
||||||
|
type InlineSpan struct {
|
||||||
|
Text string
|
||||||
|
Bold bool
|
||||||
|
Italic bool
|
||||||
|
Code bool // inline code (`…`)
|
||||||
|
}
|
||||||
|
|
||||||
|
// RenderHeader renders a section heading per IHK/DIN 5008 rules:
|
||||||
|
// - Font: Helvetica Bold, 14pt
|
||||||
|
// - Before: 2 blank lines if not at the top of the page
|
||||||
|
// - After: 1 blank line
|
||||||
|
// - Level 1 headings always start on a new page
|
||||||
|
//
|
||||||
|
// The heading is also recorded in the TOC.
|
||||||
|
func (r *IHKRenderer) RenderHeader(level int, title string) {
|
||||||
|
if level == 1 {
|
||||||
|
// Major sections start on a new page per IHK convention.
|
||||||
|
r.pdf.AddPage()
|
||||||
|
} else if !r.isAtPageTop() {
|
||||||
|
// IHK: two blank lines before a heading that is not at the page top.
|
||||||
|
r.pdf.Ln(dinSpaceBeforeHeading)
|
||||||
|
}
|
||||||
|
|
||||||
|
r.RecordHeader(level, title)
|
||||||
|
|
||||||
|
r.pdf.SetFont("Helvetica", "B", dinFontHeading)
|
||||||
|
r.pdf.MultiCell(0, dinLineHtHeading, r.tr(title), "", "L", false)
|
||||||
|
// IHK: one blank line (Leerzeile) after every heading.
|
||||||
|
r.pdf.Ln(dinSpaceAfterHeading)
|
||||||
|
}
|
||||||
|
|
||||||
|
// RenderParagraphSpans renders a paragraph from pre-parsed inline spans.
|
||||||
|
//
|
||||||
|
// Plain paragraphs (no inline formatting) use MultiCell with Blocksatz (justified)
|
||||||
|
// alignment as required by IHK. Paragraphs with mixed bold/italic use Write()
|
||||||
|
// which produces left-aligned output — an inherent fpdf limitation for mixed fonts.
|
||||||
|
func (r *IHKRenderer) RenderParagraphSpans(spans []InlineSpan) {
|
||||||
|
if len(spans) == 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
hasFormatting := false
|
||||||
|
for _, s := range spans {
|
||||||
|
if s.Bold || s.Italic || s.Code {
|
||||||
|
hasFormatting = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if !hasFormatting {
|
||||||
|
// All plain text: collect and render justified (Blocksatz).
|
||||||
|
text := ""
|
||||||
|
for _, s := range spans {
|
||||||
|
text += s.Text
|
||||||
|
}
|
||||||
|
r.pdf.SetFont("Helvetica", "", dinFontBody)
|
||||||
|
r.pdf.MultiCell(0, dinLineHtBody, r.tr(text), "", "J", false)
|
||||||
|
} else {
|
||||||
|
// Mixed formatting: render span-by-span using Write().
|
||||||
|
for _, span := range spans {
|
||||||
|
style := fontStyle(span.Bold, span.Italic)
|
||||||
|
if span.Code {
|
||||||
|
r.pdf.SetFont("Courier", style, dinFontBody)
|
||||||
|
} else {
|
||||||
|
r.pdf.SetFont("Helvetica", style, dinFontBody)
|
||||||
|
}
|
||||||
|
r.pdf.Write(dinLineHtBody, r.tr(span.Text))
|
||||||
|
}
|
||||||
|
r.pdf.Ln(dinLineHtBody)
|
||||||
|
}
|
||||||
|
r.pdf.Ln(dinSpaceAfterParagraph)
|
||||||
|
}
|
||||||
|
|
||||||
|
// RenderListItem renders a single list item with a bullet prefix.
|
||||||
|
// ordered: true → bullet "•"; false → numbered with index.
|
||||||
|
// indent: nesting depth (0 = top level).
|
||||||
|
func (r *IHKRenderer) RenderListItem(spans []InlineSpan, ordered bool, index, indent int) {
|
||||||
|
r.pdf.SetFont("Helvetica", "", dinFontBody)
|
||||||
|
|
||||||
|
lm, _, _, _ := r.pdf.GetMargins()
|
||||||
|
indentMM := float64(indent+1) * 8.0
|
||||||
|
r.pdf.SetX(lm + indentMM)
|
||||||
|
|
||||||
|
prefix := "• "
|
||||||
|
if ordered {
|
||||||
|
prefix = strconv.Itoa(index) + ". "
|
||||||
|
}
|
||||||
|
|
||||||
|
hasFormatting := false
|
||||||
|
for _, s := range spans {
|
||||||
|
if s.Bold || s.Italic || s.Code {
|
||||||
|
hasFormatting = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
uw := r.usableWidth() - indentMM
|
||||||
|
if !hasFormatting {
|
||||||
|
text := prefix
|
||||||
|
for _, s := range spans {
|
||||||
|
text += s.Text
|
||||||
|
}
|
||||||
|
r.pdf.MultiCell(uw, dinLineHtBody, r.tr(text), "", "L", false)
|
||||||
|
} else {
|
||||||
|
r.pdf.Write(dinLineHtBody, r.tr(prefix))
|
||||||
|
for _, span := range spans {
|
||||||
|
style := fontStyle(span.Bold, span.Italic)
|
||||||
|
if span.Code {
|
||||||
|
r.pdf.SetFont("Courier", style, dinFontBody)
|
||||||
|
} else {
|
||||||
|
r.pdf.SetFont("Helvetica", style, dinFontBody)
|
||||||
|
}
|
||||||
|
r.pdf.Write(dinLineHtBody, r.tr(span.Text))
|
||||||
|
}
|
||||||
|
r.pdf.Ln(dinLineHtBody)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// RenderTable renders a data table with a header row (grey background) and
|
||||||
|
// a auto-repeat header on page breaks. Each table is numbered and recorded
|
||||||
|
// in the Tabellenverzeichnis per IHK section 2.4.
|
||||||
|
//
|
||||||
|
// data[0] is the header row; data[1:] are body rows.
|
||||||
|
func (r *IHKRenderer) RenderTable(data [][]string, caption string) {
|
||||||
|
if len(data) == 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
r.tableCount++
|
||||||
|
label := fmt.Sprintf("Tab. %d", r.tableCount)
|
||||||
|
if caption != "" {
|
||||||
|
label += ": " + caption
|
||||||
|
}
|
||||||
|
r.RecordTable(label)
|
||||||
|
|
||||||
|
header := data[0]
|
||||||
|
numCols := len(header)
|
||||||
|
uw := r.usableWidth()
|
||||||
|
colW := uw / float64(numCols)
|
||||||
|
|
||||||
|
// renderHeaderRow draws the (grey) header row, optionally with a "(Fortsetzung)" suffix.
|
||||||
|
renderHeaderRow := func(continued bool) {
|
||||||
|
title := label
|
||||||
|
if continued {
|
||||||
|
title += " (Fortsetzung)"
|
||||||
|
}
|
||||||
|
r.pdf.SetFont("Helvetica", "B", dinFontCaption)
|
||||||
|
r.pdf.CellFormat(0, dinLineHtCaption+2, r.tr(title), "", 1, "L", false, 0, "")
|
||||||
|
|
||||||
|
r.pdf.SetFillColor(230, 230, 230)
|
||||||
|
r.pdf.SetFont("Helvetica", "B", dinFontCaption)
|
||||||
|
for _, col := range header {
|
||||||
|
r.pdf.CellFormat(colW, dinLineHtCaption+2, r.tr(col), "1", 0, "C", true, 0, "")
|
||||||
|
}
|
||||||
|
r.pdf.Ln(-1)
|
||||||
|
}
|
||||||
|
|
||||||
|
renderHeaderRow(false)
|
||||||
|
|
||||||
|
r.pdf.SetFont("Helvetica", "", dinFontCaption)
|
||||||
|
r.pdf.SetFillColor(255, 255, 255)
|
||||||
|
|
||||||
|
_, pageH := r.pdf.GetPageSize()
|
||||||
|
_, _, _, bm := r.pdf.GetMargins()
|
||||||
|
rowH := dinLineHtCaption + 2
|
||||||
|
|
||||||
|
for i := 1; i < len(data); i++ {
|
||||||
|
if r.pdf.GetY()+rowH > pageH-bm {
|
||||||
|
r.pdf.AddPage()
|
||||||
|
renderHeaderRow(true)
|
||||||
|
r.pdf.SetFont("Helvetica", "", dinFontCaption)
|
||||||
|
}
|
||||||
|
for _, col := range data[i] {
|
||||||
|
r.pdf.CellFormat(colW, rowH, r.tr(col), "1", 0, "L", false, 0, "")
|
||||||
|
}
|
||||||
|
r.pdf.Ln(-1)
|
||||||
|
}
|
||||||
|
r.pdf.Ln(dinSpaceAfterParagraph)
|
||||||
|
}
|
||||||
|
|
||||||
|
// RenderImage embeds an image with a caption below it.
|
||||||
|
// The image is scaled to fit the printable width and the remaining page height.
|
||||||
|
// If the image does not fit on the current page, a new page is started.
|
||||||
|
// The caption is recorded in the Abbildungsverzeichnis.
|
||||||
|
func (r *IHKRenderer) RenderImage(path string, caption string) {
|
||||||
|
r.pdf.Ln(dinSpaceAfterParagraph)
|
||||||
|
|
||||||
|
info := r.ensureImageRegistered(path)
|
||||||
|
if info == nil {
|
||||||
|
r.pdf.SetFont("Helvetica", "I", dinFontCaption)
|
||||||
|
r.pdf.CellFormat(0, dinLineHtCaption, r.tr("[Bild nicht gefunden: "+path+"]"),
|
||||||
|
"1", 1, "C", false, 0, "")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
imgW := info.Width() * ptToMM
|
||||||
|
imgH := info.Height() * ptToMM
|
||||||
|
|
||||||
|
uw := r.usableWidth()
|
||||||
|
captionH := dinLineHtCaption + 4
|
||||||
|
|
||||||
|
// Scale to available width
|
||||||
|
displayW := uw
|
||||||
|
displayH := imgH * (displayW / imgW)
|
||||||
|
|
||||||
|
_, pageH := r.pdf.GetPageSize()
|
||||||
|
_, _, _, bm := r.pdf.GetMargins()
|
||||||
|
availH := pageH - r.pdf.GetY() - bm - captionH
|
||||||
|
|
||||||
|
if displayH > availH {
|
||||||
|
if availH < 20 {
|
||||||
|
// Almost no space left — start a new page
|
||||||
|
r.pdf.AddPage()
|
||||||
|
availH = pageH - r.pdf.GetY() - bm - captionH
|
||||||
|
}
|
||||||
|
// Scale down to fit height
|
||||||
|
displayH = availH
|
||||||
|
displayW = imgW * (displayH / imgH)
|
||||||
|
if displayW > uw {
|
||||||
|
displayW = uw
|
||||||
|
displayH = imgH * (displayW / imgW)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
lm, _, _, _ := r.pdf.GetMargins()
|
||||||
|
posX := lm + (uw-displayW)/2
|
||||||
|
currentY := r.pdf.GetY()
|
||||||
|
|
||||||
|
r.pdf.ImageOptions(path, posX, currentY, displayW, displayH, false,
|
||||||
|
fpdf.ImageOptions{ReadDpi: true}, 0, "")
|
||||||
|
r.pdf.SetY(currentY + displayH + 2)
|
||||||
|
|
||||||
|
// Figure caption and TOC registration
|
||||||
|
r.figureCount++
|
||||||
|
label := fmt.Sprintf("Abb. %d", r.figureCount)
|
||||||
|
if caption != "" {
|
||||||
|
label += ": " + caption
|
||||||
|
}
|
||||||
|
r.RecordFigure(label)
|
||||||
|
|
||||||
|
r.pdf.SetFont("Helvetica", "I", dinFontCaption)
|
||||||
|
r.pdf.CellFormat(0, captionH, r.tr(label), "", 1, "C", false, 0, "")
|
||||||
|
r.pdf.Ln(dinSpaceAfterParagraph)
|
||||||
|
}
|
||||||
|
|
||||||
|
// fontStyle returns the fpdf font style string for a given bold/italic combination.
|
||||||
|
func fontStyle(bold, italic bool) string {
|
||||||
|
switch {
|
||||||
|
case bold && italic:
|
||||||
|
return "BI"
|
||||||
|
case bold:
|
||||||
|
return "B"
|
||||||
|
case italic:
|
||||||
|
return "I"
|
||||||
|
default:
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
// NumberingType controls the page numbering style rendered in the footer.
|
||||||
|
type NumberingType int
|
||||||
|
|
||||||
|
const (
|
||||||
|
NumNone NumberingType = iota // No page number (title page, declaration)
|
||||||
|
NumRoman // Roman numerals for front matter (II, III, …)
|
||||||
|
NumArabic // Arabic numerals for main body (1, 2, …)
|
||||||
|
)
|
||||||
|
|
||||||
|
// toRoman converts a positive integer to an uppercase Roman numeral string.
|
||||||
|
func toRoman(n int) string {
|
||||||
|
if n <= 0 {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
vals := []int{1000, 900, 500, 400, 100, 90, 50, 40, 10, 9, 5, 4, 1}
|
||||||
|
syms := []string{"M", "CM", "D", "CD", "C", "XC", "L", "XL", "X", "IX", "V", "IV", "I"}
|
||||||
|
result := ""
|
||||||
|
for i, v := range vals {
|
||||||
|
for n >= v {
|
||||||
|
n -= v
|
||||||
|
result += syms[i]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
+249
@@ -0,0 +1,249 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"sort"
|
||||||
|
|
||||||
|
"github.com/go-pdf/fpdf"
|
||||||
|
)
|
||||||
|
|
||||||
|
// RenderTitlePage renders the cover page without a page number.
|
||||||
|
// Layout follows the IHK Chemnitz template (section 2.2 of the guidelines).
|
||||||
|
func (r *IHKRenderer) RenderTitlePage() {
|
||||||
|
r.numType = NumNone
|
||||||
|
r.pdf.AddPage()
|
||||||
|
|
||||||
|
// Use symmetrical margins on the title page for visual centering.
|
||||||
|
r.pdf.SetLeftMargin(dinMarginLeft)
|
||||||
|
r.pdf.SetRightMargin(dinMarginLeft)
|
||||||
|
defer func() {
|
||||||
|
r.pdf.SetLeftMargin(dinMarginLeft)
|
||||||
|
r.pdf.SetRightMargin(dinMarginRight)
|
||||||
|
}()
|
||||||
|
|
||||||
|
// Exam type and profession
|
||||||
|
r.pdf.SetFont("Helvetica", "B", 16)
|
||||||
|
r.pdf.CellFormat(0, 12, r.tr("Abschlussprüfung zum"), "", 1, "C", false, 0, "")
|
||||||
|
r.pdf.CellFormat(0, 12, r.tr(r.config.Student.Profession), "", 1, "C", false, 0, "")
|
||||||
|
|
||||||
|
r.pdf.Ln(20)
|
||||||
|
|
||||||
|
r.pdf.SetFont("Helvetica", "", dinFontBody)
|
||||||
|
r.pdf.CellFormat(0, dinLineHtBody, r.tr("Projektarbeit von"), "", 1, "C", false, 0, "")
|
||||||
|
r.pdf.Ln(4)
|
||||||
|
|
||||||
|
// Candidate name — bold, 14 pt
|
||||||
|
r.pdf.SetFont("Helvetica", "B", dinFontHeading)
|
||||||
|
r.pdf.CellFormat(0, dinLineHtHeading, r.tr(r.config.Student.Name), "", 1, "C", false, 0, "")
|
||||||
|
|
||||||
|
r.pdf.Ln(20)
|
||||||
|
|
||||||
|
// Project title — bold, 16 pt, centred, word-wrapped
|
||||||
|
r.pdf.SetFont("Helvetica", "B", 16)
|
||||||
|
r.pdf.MultiCell(0, 10, r.tr(r.config.Project.Title), "", "C", false)
|
||||||
|
|
||||||
|
if r.config.Project.Subtitle != "" {
|
||||||
|
r.pdf.Ln(4)
|
||||||
|
r.pdf.SetFont("Helvetica", "I", dinFontBody)
|
||||||
|
r.pdf.MultiCell(0, dinLineHtBody, r.tr(r.config.Project.Subtitle), "", "C", false)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Bottom block: exam period, training company, supervisor
|
||||||
|
r.pdf.SetY(-80)
|
||||||
|
r.pdf.SetFont("Helvetica", "", dinFontBody)
|
||||||
|
labelW := 60.0
|
||||||
|
|
||||||
|
r.pdf.CellFormat(labelW, dinLineHtBody, r.tr("Prüfungsperiode:"), "", 0, "L", false, 0, "")
|
||||||
|
r.pdf.MultiCell(0, dinLineHtBody, r.tr(r.config.Project.Period), "", "L", false)
|
||||||
|
|
||||||
|
r.pdf.CellFormat(labelW, dinLineHtBody, r.tr("Ausbildungsbetrieb:"), "", 0, "L", false, 0, "")
|
||||||
|
r.pdf.MultiCell(0, dinLineHtBody, r.tr(r.config.Student.Company), "", "L", false)
|
||||||
|
|
||||||
|
r.pdf.CellFormat(labelW, dinLineHtBody, r.tr("Projektbetreuer:"), "", 0, "L", false, 0, "")
|
||||||
|
r.pdf.MultiCell(0, dinLineHtBody, r.tr(r.config.Student.Supervisor), "", "L", false)
|
||||||
|
}
|
||||||
|
|
||||||
|
// RenderDeclarationPage renders the declaration of authenticity without a page
|
||||||
|
// number, as required by IHK Chemnitz (section 2.12 of the guidelines).
|
||||||
|
// This page must be physically inserted in every submitted copy.
|
||||||
|
func (r *IHKRenderer) RenderDeclarationPage() {
|
||||||
|
r.numType = NumNone
|
||||||
|
r.pdf.AddPage()
|
||||||
|
|
||||||
|
r.pdf.SetFont("Helvetica", "B", dinFontHeading)
|
||||||
|
r.pdf.CellFormat(0, dinLineHtHeading+4, r.tr("Erklärung"), "", 1, "C", false, 0, "")
|
||||||
|
r.pdf.Ln(dinSpaceAfterHeading)
|
||||||
|
|
||||||
|
// Legally required declaration text — must remain in German.
|
||||||
|
r.pdf.SetFont("Helvetica", "", dinFontBody)
|
||||||
|
declaration := fmt.Sprintf(
|
||||||
|
"Ich versichere durch meine Unterschrift, dass ich diese Projektarbeit"+
|
||||||
|
" mit dem Thema \"%s\" selbstständig, ohne fremde Hilfe angefertigt,"+
|
||||||
|
" alle Stellen, die ich wörtlich oder annähernd wörtlich aus"+
|
||||||
|
" Veröffentlichungen entnommen, als solche kenntlich gemacht und mich"+
|
||||||
|
" auch keiner anderen als der angegebenen Literatur oder sonstiger"+
|
||||||
|
" Hilfsmittel bedient habe. Die Projektarbeit hat in dieser oder"+
|
||||||
|
" ähnlicher Form weder der Industrie- und Handelskammer Chemnitz noch"+
|
||||||
|
" einer anderen Prüfungsinstitution vorgelegen.",
|
||||||
|
r.config.Project.Title,
|
||||||
|
)
|
||||||
|
r.pdf.MultiCell(0, dinLineHtBody, r.tr(declaration), "", "J", false)
|
||||||
|
|
||||||
|
r.pdf.Ln(dinLineHtBody)
|
||||||
|
additional := "Mir ist bekannt, dass gemäß der Prüfungsordnung für die Durchführung" +
|
||||||
|
" von Abschlussprüfungen der Industrie- und Handelskammer Chemnitz" +
|
||||||
|
" Täuschungshandlungen zum Ausschluss von der Prüfung führen können und" +
|
||||||
|
" die Prüfung als nicht bestanden erklärt werden kann."
|
||||||
|
r.pdf.MultiCell(0, dinLineHtBody, r.tr(additional), "", "J", false)
|
||||||
|
|
||||||
|
r.pdf.Ln(20)
|
||||||
|
r.pdf.CellFormat(0, dinLineHtBody, r.tr("Ort, Datum, Unterschrift (mit Vor- und Nachnamen)"), "", 1, "L", false, 0, "")
|
||||||
|
r.pdf.Ln(4)
|
||||||
|
|
||||||
|
// Signature line
|
||||||
|
r.pdf.SetDrawColor(0, 0, 0)
|
||||||
|
x, y := r.pdf.GetXY()
|
||||||
|
r.pdf.Line(x, y, x+80, y)
|
||||||
|
}
|
||||||
|
|
||||||
|
// RenderBibliography renders the bibliography sorted alphabetically per IHK
|
||||||
|
// rule 2.8.1. Skipped when no @Quelle directives were found in the document.
|
||||||
|
func (r *IHKRenderer) RenderBibliography() {
|
||||||
|
if len(r.sources) == 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
r.RenderHeader(1, "Literaturverzeichnis")
|
||||||
|
|
||||||
|
sorted := make([]string, len(r.sources))
|
||||||
|
copy(sorted, r.sources)
|
||||||
|
sort.Strings(sorted)
|
||||||
|
|
||||||
|
r.pdf.SetFont("Helvetica", "", dinFontBody)
|
||||||
|
for _, source := range sorted {
|
||||||
|
r.pdf.MultiCell(0, dinLineHtBody, r.tr("– "+source), "", "J", false)
|
||||||
|
r.pdf.Ln(2)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// RenderAppendices renders the appendix section followed by individual annex
|
||||||
|
// pages. The opening page contains the index of all annexes. Each annex image
|
||||||
|
// is scaled to fill the available page area.
|
||||||
|
func (r *IHKRenderer) RenderAppendices() {
|
||||||
|
if len(r.appendices) == 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
r.RenderHeader(1, "Anhang")
|
||||||
|
|
||||||
|
// Annex index
|
||||||
|
r.pdf.SetFont("Helvetica", "B", dinFontBody)
|
||||||
|
r.pdf.CellFormat(0, dinLineHtBody, r.tr("Anlagenverzeichnis"), "", 1, "L", false, 0, "")
|
||||||
|
r.pdf.Ln(dinSpaceAfterHeading)
|
||||||
|
r.pdf.SetFont("Helvetica", "", dinFontBody)
|
||||||
|
for i, app := range r.appendices {
|
||||||
|
r.pdf.CellFormat(0, dinLineHtBody,
|
||||||
|
r.tr(fmt.Sprintf("Anlage %d: %s", i+1, app.Title)), "", 1, "L", false, 0, "")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Individual annex pages
|
||||||
|
for i, app := range r.appendices {
|
||||||
|
r.pdf.AddPage()
|
||||||
|
r.pdf.SetFont("Helvetica", "B", dinFontBody)
|
||||||
|
r.pdf.CellFormat(0, dinLineHtBody,
|
||||||
|
r.tr(fmt.Sprintf("Anlage %d: %s", i+1, app.Title)), "", 1, "L", false, 0, "")
|
||||||
|
r.pdf.Ln(dinSpaceAfterHeading)
|
||||||
|
r.renderAppendixImage(app.Path)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// renderAppendixImage scales and embeds an image to fill the remaining page area.
|
||||||
|
func (r *IHKRenderer) renderAppendixImage(path string) {
|
||||||
|
info := r.ensureImageRegistered(path)
|
||||||
|
if info == nil {
|
||||||
|
r.pdf.CellFormat(0, dinLineHtBody, r.tr("[Image could not be loaded: "+path+"]"),
|
||||||
|
"1", 1, "C", false, 0, "")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
_, pageH := r.pdf.GetPageSize()
|
||||||
|
lm, _, _, bm := r.pdf.GetMargins()
|
||||||
|
uw := r.usableWidth()
|
||||||
|
availH := pageH - r.pdf.GetY() - bm - 10
|
||||||
|
|
||||||
|
imgW := info.Width() * ptToMM
|
||||||
|
imgH := info.Height() * ptToMM
|
||||||
|
|
||||||
|
// Scale to fill available width, then clamp to available height.
|
||||||
|
displayW := uw
|
||||||
|
displayH := imgH * (displayW / imgW)
|
||||||
|
if displayH > availH {
|
||||||
|
scale := availH / displayH
|
||||||
|
displayH = availH
|
||||||
|
displayW = displayW * scale
|
||||||
|
}
|
||||||
|
|
||||||
|
posX := lm + (uw-displayW)/2
|
||||||
|
r.pdf.ImageOptions(path, posX, r.pdf.GetY(), displayW, displayH, false,
|
||||||
|
fpdf.ImageOptions{ReadDpi: true}, 0, "")
|
||||||
|
}
|
||||||
|
|
||||||
|
// RenderAbbreviations renders the list of abbreviations from the YAML config.
|
||||||
|
// It is placed after the TOC and uses Roman page numbering.
|
||||||
|
func (r *IHKRenderer) RenderAbbreviations() {
|
||||||
|
if len(r.config.Abbreviations) == 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
r.pdf.AddPage()
|
||||||
|
r.RecordHeader(1, "Abkürzungsverzeichnis")
|
||||||
|
|
||||||
|
r.pdf.SetFont("Helvetica", "B", dinFontHeading)
|
||||||
|
r.pdf.CellFormat(0, dinLineHtHeading+4, r.tr("Abkürzungsverzeichnis"), "", 1, "L", false, 0, "")
|
||||||
|
r.pdf.Ln(dinSpaceAfterHeading)
|
||||||
|
|
||||||
|
uw := r.usableWidth()
|
||||||
|
abbrColW := 40.0
|
||||||
|
meaningColW := uw - abbrColW
|
||||||
|
|
||||||
|
// Header row with grey fill
|
||||||
|
r.pdf.SetFillColor(230, 230, 230)
|
||||||
|
r.pdf.SetFont("Helvetica", "B", dinFontBody)
|
||||||
|
r.pdf.CellFormat(abbrColW, dinLineHtBody, r.tr("Abkürzung"), "1", 0, "L", true, 0, "")
|
||||||
|
r.pdf.CellFormat(meaningColW, dinLineHtBody, r.tr("Bedeutung"), "1", 1, "L", true, 0, "")
|
||||||
|
|
||||||
|
r.pdf.SetFillColor(255, 255, 255)
|
||||||
|
r.pdf.SetFont("Helvetica", "", dinFontBody)
|
||||||
|
for _, a := range r.config.Abbreviations {
|
||||||
|
r.pdf.CellFormat(abbrColW, dinLineHtBody, r.tr(a.Abbr), "1", 0, "L", false, 0, "")
|
||||||
|
r.pdf.CellFormat(meaningColW, dinLineHtBody, r.tr(a.Meaning), "1", 1, "L", false, 0, "")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// RenderGlossary renders the glossary from the YAML config.
|
||||||
|
// It is placed after the appendices and uses Arabic page numbering.
|
||||||
|
func (r *IHKRenderer) RenderGlossary() {
|
||||||
|
if len(r.config.Glossary) == 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
r.RenderHeader(1, "Glossar")
|
||||||
|
|
||||||
|
for _, entry := range r.config.Glossary {
|
||||||
|
r.pdf.SetFont("Helvetica", "B", dinFontBody)
|
||||||
|
r.pdf.CellFormat(0, dinLineHtBody, r.tr(entry.Term), "", 1, "L", false, 0, "")
|
||||||
|
r.pdf.SetFont("Helvetica", "", dinFontBody)
|
||||||
|
r.pdf.MultiCell(0, dinLineHtBody, r.tr(entry.Definition), "", "J", false)
|
||||||
|
r.pdf.Ln(4)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ensureImageRegistered registers an image with fpdf if not already known
|
||||||
|
// and returns its metadata. Returns nil if the image cannot be loaded.
|
||||||
|
func (r *IHKRenderer) ensureImageRegistered(path string) *fpdf.ImageInfoType {
|
||||||
|
info := r.pdf.GetImageInfo(path)
|
||||||
|
if info == nil {
|
||||||
|
r.pdf.RegisterImageOptions(path, fpdf.ImageOptions{ReadDpi: true})
|
||||||
|
info = r.pdf.GetImageInfo(path)
|
||||||
|
}
|
||||||
|
return info
|
||||||
|
}
|
||||||
|
|
||||||
|
// ptToMM converts PDF points to millimetres (1 pt = 25.4 / 72 mm).
|
||||||
|
const ptToMM = 25.4 / 72.0
|
||||||
+89
-467
@@ -1,56 +1,78 @@
|
|||||||
|
// Package main implements a Markdown-to-PDF converter that produces documents
|
||||||
|
// compliant with IHK Chemnitz project documentation guidelines (Verordnung 2020)
|
||||||
|
// and DIN 5008 formatting rules.
|
||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
|
||||||
"github.com/go-pdf/fpdf"
|
|
||||||
"sort"
|
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
|
"github.com/go-pdf/fpdf"
|
||||||
)
|
)
|
||||||
|
|
||||||
type NumberingType int
|
// IHK Chemnitz / DIN 5008 format constants.
|
||||||
|
// Source: "Hinweise zur Erarbeitung der Dokumentation über die Projektarbeit",
|
||||||
|
// IHK Chemnitz, Verordnung 2020.
|
||||||
const (
|
const (
|
||||||
NumNone NumberingType = iota
|
// Page margins in mm
|
||||||
NumRoman
|
dinMarginLeft = 30.0 // 3.0 cm left margin
|
||||||
NumArabic
|
dinMarginRight = 40.0 // 4.0 cm right margin — Korrekturrand (examiner correction space)
|
||||||
|
dinMarginTop = 20.0 // 2.0 cm top margin
|
||||||
|
dinMarginBottom = 25.0 // 2.5 cm bottom margin
|
||||||
|
|
||||||
|
// Font sizes in pt — Arial/Helvetica, black
|
||||||
|
dinFontBody = 12.0 // body text
|
||||||
|
dinFontHeading = 14.0 // headings, bold
|
||||||
|
dinFontCaption = 10.0 // captions and footnotes
|
||||||
|
|
||||||
|
// Line heights in mm — 1½ line spacing: pt × 1.5 × (25.4 / 72)
|
||||||
|
dinLineHtBody = 6.35 // 12 pt × 1.5 = 18 pt = 6.35 mm
|
||||||
|
dinLineHtHeading = 7.41 // 14 pt × 1.5 = 21 pt = 7.41 mm
|
||||||
|
dinLineHtCaption = 5.29 // 10 pt × 1.5 = 15 pt = 5.29 mm
|
||||||
|
|
||||||
|
// Vertical spacing in mm
|
||||||
|
// IHK: two blank lines before a heading that is not at the top of the page;
|
||||||
|
// one blank line after every heading.
|
||||||
|
dinSpaceBeforeHeading = 2 * dinLineHtBody // ≈ 12.7 mm
|
||||||
|
dinSpaceAfterHeading = dinLineHtBody // ≈ 6.35 mm
|
||||||
|
dinSpaceAfterParagraph = 4.0 // between body paragraphs
|
||||||
)
|
)
|
||||||
|
|
||||||
type TOCItem struct {
|
// Appendix holds the title and image path for one annex entry.
|
||||||
Level int
|
|
||||||
Title string
|
|
||||||
PageStr string
|
|
||||||
}
|
|
||||||
|
|
||||||
type TableItem struct {
|
|
||||||
Title string
|
|
||||||
PageStr string
|
|
||||||
}
|
|
||||||
|
|
||||||
type Appendix struct {
|
type Appendix struct {
|
||||||
Title string
|
Title string
|
||||||
Path string
|
Path string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// IHKRenderer is the central PDF generator for IHK Chemnitz project documentation.
|
||||||
|
// All DIN 5008 formatting rules are applied through its methods.
|
||||||
|
//
|
||||||
|
// Usage: create with NewIHKRenderer, call Render* methods in document order,
|
||||||
|
// then call Save. The two-pass rendering in main.go fills the TOC correctly.
|
||||||
type IHKRenderer struct {
|
type IHKRenderer struct {
|
||||||
pdf *fpdf.Fpdf
|
pdf *fpdf.Fpdf
|
||||||
config Config
|
config Config
|
||||||
numType NumberingType
|
numType NumberingType
|
||||||
tocItems []TOCItem
|
tocItems []TOCItem
|
||||||
tableItems []TableItem
|
tableItems []TableItem
|
||||||
|
figureItems []FigureItem
|
||||||
sources []string
|
sources []string
|
||||||
appendices []Appendix
|
appendices []Appendix
|
||||||
|
// pageOffset is the PDF page number of the last Roman-numbered page.
|
||||||
|
// Main body page 1 is rendered as PDF page (pageOffset + 1).
|
||||||
pageOffset int
|
pageOffset int
|
||||||
tableCount int
|
tableCount int
|
||||||
|
figureCount int
|
||||||
|
// tr translates UTF-8 strings to the Latin-1 encoding used by fpdf.
|
||||||
tr func(string) string
|
tr func(string) string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// NewIHKRenderer constructs a renderer pre-configured with DIN 5008 margins
|
||||||
|
// and an auto-footer that renders the correct page number style per section.
|
||||||
func NewIHKRenderer(config Config) *IHKRenderer {
|
func NewIHKRenderer(config Config) *IHKRenderer {
|
||||||
pdf := fpdf.New("P", "mm", "A4", "")
|
pdf := fpdf.New("P", "mm", "A4", "")
|
||||||
|
pdf.SetMargins(dinMarginLeft, dinMarginTop, dinMarginRight)
|
||||||
// Margins in mm: Top 20, Bottom 25, Left 30, Right 40
|
pdf.SetAutoPageBreak(true, dinMarginBottom)
|
||||||
pdf.SetMargins(30, 20, 40)
|
|
||||||
pdf.SetAutoPageBreak(true, 25)
|
|
||||||
|
|
||||||
r := &IHKRenderer{
|
r := &IHKRenderer{
|
||||||
pdf: pdf,
|
pdf: pdf,
|
||||||
@@ -58,467 +80,57 @@ func NewIHKRenderer(config Config) *IHKRenderer {
|
|||||||
numType: NumNone,
|
numType: NumNone,
|
||||||
tocItems: make([]TOCItem, 0),
|
tocItems: make([]TOCItem, 0),
|
||||||
tableItems: make([]TableItem, 0),
|
tableItems: make([]TableItem, 0),
|
||||||
|
figureItems: make([]FigureItem, 0),
|
||||||
sources: make([]string, 0),
|
sources: make([]string, 0),
|
||||||
appendices: make([]Appendix, 0),
|
appendices: make([]Appendix, 0),
|
||||||
tr: pdf.UnicodeTranslatorFromDescriptor(""),
|
tr: pdf.UnicodeTranslatorFromDescriptor(""),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// IHK: page number centered at the bottom of every numbered page.
|
||||||
pdf.SetFooterFunc(func() {
|
pdf.SetFooterFunc(func() {
|
||||||
if r.numType == NumNone {
|
if r.numType == NumNone {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
pdf.SetY(-15)
|
pdf.SetY(-15)
|
||||||
pdf.SetFont("Helvetica", "", 10)
|
pdf.SetFont("Helvetica", "", dinFontCaption)
|
||||||
|
|
||||||
var pageStr string
|
var pageStr string
|
||||||
if r.numType == NumRoman {
|
switch r.numType {
|
||||||
|
case NumRoman:
|
||||||
pageStr = toRoman(pdf.PageNo())
|
pageStr = toRoman(pdf.PageNo())
|
||||||
} else {
|
case NumArabic:
|
||||||
// Arabic numbering starts at 1 for the main body
|
dp := pdf.PageNo() - r.pageOffset
|
||||||
displayPage := pdf.PageNo() - r.pageOffset
|
if dp <= 0 {
|
||||||
if displayPage <= 0 {
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
pageStr = strconv.Itoa(displayPage)
|
pageStr = strconv.Itoa(dp)
|
||||||
}
|
}
|
||||||
|
|
||||||
pdf.CellFormat(0, 10, pageStr, "", 0, "C", false, 0, "")
|
pdf.CellFormat(0, 10, pageStr, "", 0, "C", false, 0, "")
|
||||||
})
|
})
|
||||||
|
|
||||||
return r
|
return r
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *IHKRenderer) RenderTOC() {
|
// StartFrontMatter activates Roman-numeral page numbering.
|
||||||
r.numType = NumRoman
|
// Must be called before adding the first front-matter page (i.e., before RenderTOC).
|
||||||
r.pdf.AddPage()
|
// The title page (page 1) is unnumbered; the TOC appears as page II.
|
||||||
r.pdf.SetFont("Helvetica", "B", 14)
|
|
||||||
r.pdf.CellFormat(0, 20, r.tr("Inhaltsverzeichnis"), "", 1, "L", false, 0, "")
|
|
||||||
r.pdf.Ln(5)
|
|
||||||
|
|
||||||
// TOC Header as a table
|
|
||||||
totalWidth, _ := r.pdf.GetPageSize()
|
|
||||||
lm, _, rm, _ := r.pdf.GetMargins()
|
|
||||||
usableWidth := totalWidth - lm - rm
|
|
||||||
|
|
||||||
r.pdf.SetFont("Helvetica", "B", 12)
|
|
||||||
r.pdf.CellFormat(usableWidth-20, 10, r.tr("Inhalt"), "", 0, "L", false, 0, "")
|
|
||||||
r.pdf.CellFormat(20, 10, r.tr("Seite"), "", 1, "R", false, 0, "")
|
|
||||||
r.pdf.Ln(2)
|
|
||||||
|
|
||||||
r.pdf.SetFont("Helvetica", "", 12)
|
|
||||||
_, lineHt := r.pdf.GetFontSize()
|
|
||||||
lineHt *= 1.5
|
|
||||||
|
|
||||||
for _, item := range r.tocItems {
|
|
||||||
indent := float64((item.Level - 1) * 10)
|
|
||||||
r.pdf.SetX(lm + indent)
|
|
||||||
|
|
||||||
title := r.tr(item.Title)
|
|
||||||
pageStr := item.PageStr
|
|
||||||
|
|
||||||
titleWidth := r.pdf.GetStringWidth(title)
|
|
||||||
pageWidth := r.pdf.GetStringWidth(pageStr)
|
|
||||||
|
|
||||||
// Available width for dots
|
|
||||||
availableWidth := usableWidth - indent - titleWidth - pageWidth - 4
|
|
||||||
|
|
||||||
r.pdf.CellFormat(titleWidth+2, lineHt, title, "", 0, "L", false, 0, "")
|
|
||||||
|
|
||||||
if availableWidth > 0 {
|
|
||||||
dots := ""
|
|
||||||
dotWidth := r.pdf.GetStringWidth(".")
|
|
||||||
for i := 0; float64(i)*dotWidth < availableWidth; i++ {
|
|
||||||
dots += "."
|
|
||||||
}
|
|
||||||
r.pdf.CellFormat(availableWidth, lineHt, dots, "", 0, "L", false, 0, "")
|
|
||||||
}
|
|
||||||
|
|
||||||
r.pdf.CellFormat(pageWidth+2, lineHt, pageStr, "", 1, "R", false, 0, "")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (r *IHKRenderer) RecordHeader(level int, title string) {
|
|
||||||
if r.numType != NumArabic {
|
|
||||||
return // Exclude front matter from TOC per IHK example
|
|
||||||
}
|
|
||||||
|
|
||||||
displayPage := r.pdf.PageNo() - r.pageOffset
|
|
||||||
pageStr := strconv.Itoa(displayPage)
|
|
||||||
if displayPage <= 0 {
|
|
||||||
pageStr = "1" // Fallback
|
|
||||||
}
|
|
||||||
|
|
||||||
r.tocItems = append(r.tocItems, TOCItem{
|
|
||||||
Level: level,
|
|
||||||
Title: title,
|
|
||||||
PageStr: pageStr,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
func (r *IHKRenderer) RecordTable(title string) {
|
|
||||||
displayPage := r.pdf.PageNo() - r.pageOffset
|
|
||||||
pageStr := strconv.Itoa(displayPage)
|
|
||||||
if displayPage <= 0 {
|
|
||||||
pageStr = "1"
|
|
||||||
}
|
|
||||||
r.tableItems = append(r.tableItems, TableItem{
|
|
||||||
Title: title,
|
|
||||||
PageStr: pageStr,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
func (r *IHKRenderer) RenderListOfTables() {
|
|
||||||
if len(r.tableItems) == 0 {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
r.pdf.AddPage()
|
|
||||||
r.pdf.SetFont("Helvetica", "B", 14)
|
|
||||||
r.pdf.CellFormat(0, 20, r.tr("Tabellenverzeichnis"), "", 1, "L", false, 0, "")
|
|
||||||
r.pdf.Ln(5)
|
|
||||||
|
|
||||||
totalWidth, _ := r.pdf.GetPageSize()
|
|
||||||
lm, _, rm, _ := r.pdf.GetMargins()
|
|
||||||
usableWidth := totalWidth - lm - rm
|
|
||||||
|
|
||||||
r.pdf.SetFont("Helvetica", "B", 12)
|
|
||||||
r.pdf.CellFormat(usableWidth-20, 10, r.tr("Titel"), "", 0, "L", false, 0, "")
|
|
||||||
r.pdf.CellFormat(20, 10, r.tr("Seite"), "", 1, "R", false, 0, "")
|
|
||||||
r.pdf.Ln(2)
|
|
||||||
|
|
||||||
r.pdf.SetFont("Helvetica", "", 12)
|
|
||||||
_, lineHt := r.pdf.GetFontSize()
|
|
||||||
lineHt *= 1.5
|
|
||||||
|
|
||||||
for _, item := range r.tableItems {
|
|
||||||
title := r.tr(item.Title)
|
|
||||||
pageStr := item.PageStr
|
|
||||||
titleWidth := r.pdf.GetStringWidth(title)
|
|
||||||
pageWidth := r.pdf.GetStringWidth(pageStr)
|
|
||||||
availableWidth := usableWidth - titleWidth - pageWidth - 4
|
|
||||||
|
|
||||||
r.pdf.CellFormat(titleWidth+2, lineHt, title, "", 0, "L", false, 0, "")
|
|
||||||
if availableWidth > 0 {
|
|
||||||
dots := ""
|
|
||||||
dotWidth := r.pdf.GetStringWidth(".")
|
|
||||||
for i := 0; float64(i)*dotWidth < availableWidth; i++ {
|
|
||||||
dots += "."
|
|
||||||
}
|
|
||||||
r.pdf.CellFormat(availableWidth, lineHt, dots, "", 0, "L", false, 0, "")
|
|
||||||
}
|
|
||||||
r.pdf.CellFormat(pageWidth+2, lineHt, pageStr, "", 1, "R", false, 0, "")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (r *IHKRenderer) RenderTitlePage() {
|
|
||||||
r.numType = NumNone
|
|
||||||
|
|
||||||
// Temporary symmetrical margins for title page to ensure visual centering
|
|
||||||
oldLM, oldTM, oldRM, _ := r.pdf.GetMargins()
|
|
||||||
r.pdf.SetMargins(30, 20, 30)
|
|
||||||
|
|
||||||
r.pdf.AddPage()
|
|
||||||
|
|
||||||
r.pdf.SetFont("Helvetica", "B", 16)
|
|
||||||
r.pdf.CellFormat(0, 20, r.tr("Abschlussprüfung zum ..."), "", 1, "C", false, 0, "")
|
|
||||||
r.pdf.CellFormat(0, 10, r.tr(r.config.Student.Profession), "", 1, "C", false, 0, "")
|
|
||||||
|
|
||||||
r.pdf.Ln(30)
|
|
||||||
r.pdf.SetFont("Helvetica", "", 12)
|
|
||||||
r.pdf.CellFormat(0, 10, r.tr("Projektarbeit von"), "", 1, "C", false, 0, "")
|
|
||||||
r.pdf.Ln(5)
|
|
||||||
r.pdf.SetFont("Helvetica", "B", 14)
|
|
||||||
r.pdf.CellFormat(0, 10, r.tr(r.config.Student.Name), "", 1, "C", false, 0, "")
|
|
||||||
|
|
||||||
r.pdf.Ln(30)
|
|
||||||
r.pdf.SetFont("Helvetica", "B", 16)
|
|
||||||
r.pdf.MultiCell(0, 10, r.tr(r.config.Project.Title), "", "C", false)
|
|
||||||
|
|
||||||
r.pdf.SetY(-80)
|
|
||||||
r.pdf.SetFont("Helvetica", "", 12)
|
|
||||||
r.pdf.CellFormat(60, 10, r.tr("Prüfungsperiode:"), "", 0, "L", false, 0, "")
|
|
||||||
r.pdf.CellFormat(0, 10, r.tr(r.config.Project.Period), "", 1, "L", false, 0, "")
|
|
||||||
|
|
||||||
r.pdf.CellFormat(60, 10, r.tr("Ausbildungsbetrieb:"), "", 0, "L", false, 0, "")
|
|
||||||
r.pdf.CellFormat(0, 10, r.tr(r.config.Student.Company), "", 1, "L", false, 0, "")
|
|
||||||
|
|
||||||
r.pdf.CellFormat(60, 10, r.tr("Projektbetreuer:"), "", 0, "L", false, 0, "")
|
|
||||||
r.pdf.CellFormat(0, 10, r.tr(r.config.Student.Supervisor), "", 1, "L", false, 0, "")
|
|
||||||
|
|
||||||
// Restore margins for the rest of the document
|
|
||||||
r.pdf.SetMargins(oldLM, oldTM, oldRM)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (r *IHKRenderer) RenderDeclarationPage() {
|
|
||||||
r.numType = NumNone
|
|
||||||
r.pdf.AddPage()
|
|
||||||
|
|
||||||
r.pdf.SetFont("Helvetica", "B", 14)
|
|
||||||
r.pdf.CellFormat(0, 20, r.tr("Erklärung"), "", 1, "C", false, 0, "")
|
|
||||||
r.pdf.Ln(10)
|
|
||||||
|
|
||||||
r.pdf.SetFont("Helvetica", "", 12)
|
|
||||||
text := fmt.Sprintf("Ich versichere durch meine Unterschrift, dass ich diese Projektarbeit mit dem Thema „%s“ selbstständig, ohne fremde Hilfe angefertigt, alle Stellen, die ich wörtlich oder annähernd wörtlich aus Veröffentlichungen entnommen, als solche kenntlich gemacht und mich auch keiner anderen als der angegebenen Literatur oder sonstiger Hilfsmittel bedient habe. Die Projektarbeit hat in dieser oder ähnlicher Form weder der Industrie- und Handelskammer Chemnitz noch einer anderen Prüfungsinstitution vorgelegen.", r.config.Project.Title)
|
|
||||||
|
|
||||||
r.pdf.MultiCell(0, 7.5, r.tr(text), "", "J", false)
|
|
||||||
|
|
||||||
r.pdf.Ln(20)
|
|
||||||
r.pdf.CellFormat(0, 10, r.tr("Ort, Datum, Unterschrift (mit Vor- und Nachnamen)"), "", 1, "L", false, 0, "")
|
|
||||||
}
|
|
||||||
|
|
||||||
func (r *IHKRenderer) RenderHeader(level int, title string) {
|
|
||||||
if level == 1 {
|
|
||||||
r.pdf.AddPage() // New page for major sections? Maybe optional.
|
|
||||||
}
|
|
||||||
r.RecordHeader(level, title)
|
|
||||||
r.pdf.SetFont("Helvetica", "B", 14)
|
|
||||||
r.pdf.Ln(5)
|
|
||||||
r.pdf.CellFormat(0, 10, r.tr(title), "", 1, "L", false, 0, "")
|
|
||||||
r.pdf.Ln(2)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (r *IHKRenderer) RenderParagraph(text string) {
|
|
||||||
r.pdf.SetFont("Helvetica", "", 12)
|
|
||||||
// Line height for 12pt is 12pt * 1.5 = 18pt.
|
|
||||||
// 18pt is approx 6.35mm.
|
|
||||||
r.pdf.MultiCell(0, 7.5, r.tr(text), "", "J", false)
|
|
||||||
r.pdf.Ln(4)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (r *IHKRenderer) RenderListItem(text string, bullet bool, index int) {
|
|
||||||
r.pdf.SetFont("Helvetica", "", 12)
|
|
||||||
prefix := "• "
|
|
||||||
if !bullet {
|
|
||||||
prefix = strconv.Itoa(index) + ". "
|
|
||||||
}
|
|
||||||
|
|
||||||
currentX := r.pdf.GetX()
|
|
||||||
r.pdf.SetX(currentX + 10)
|
|
||||||
r.pdf.MultiCell(0, 7.5, r.tr(prefix+text), "", "J", false)
|
|
||||||
r.pdf.SetX(currentX)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (r *IHKRenderer) RenderTable(data [][]string) {
|
|
||||||
if len(data) == 0 {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
r.tableCount++
|
|
||||||
tableTitle := fmt.Sprintf("Tab. %d", r.tableCount)
|
|
||||||
r.RecordTable(tableTitle)
|
|
||||||
|
|
||||||
r.pdf.SetFont("Helvetica", "B", 10)
|
|
||||||
header := data[0]
|
|
||||||
|
|
||||||
// Calculate column widths
|
|
||||||
numCols := len(header)
|
|
||||||
totalWidth, _ := r.pdf.GetPageSize()
|
|
||||||
lm, _, rm, _ := r.pdf.GetMargins()
|
|
||||||
usableWidth := totalWidth - lm - rm
|
|
||||||
colWidth := usableWidth / float64(numCols)
|
|
||||||
|
|
||||||
// Function to render header with optional "(Fortsetzung)"
|
|
||||||
renderHeader := func(continued bool) {
|
|
||||||
r.pdf.SetFont("Helvetica", "B", 10)
|
|
||||||
title := tableTitle
|
|
||||||
if continued {
|
|
||||||
title += " (Fortsetzung)"
|
|
||||||
}
|
|
||||||
r.pdf.CellFormat(0, 10, r.tr(title), "", 1, "L", false, 0, "")
|
|
||||||
|
|
||||||
r.pdf.SetFillColor(230, 230, 230)
|
|
||||||
for _, col := range header {
|
|
||||||
r.pdf.CellFormat(colWidth, 10, r.tr(col), "1", 0, "C", true, 0, "")
|
|
||||||
}
|
|
||||||
r.pdf.Ln(-1)
|
|
||||||
}
|
|
||||||
|
|
||||||
renderHeader(false)
|
|
||||||
|
|
||||||
r.pdf.SetFont("Helvetica", "", 10)
|
|
||||||
r.pdf.SetFillColor(255, 255, 255)
|
|
||||||
|
|
||||||
for i := 1; i < len(data); i++ {
|
|
||||||
row := data[i]
|
|
||||||
|
|
||||||
// Check for page break
|
|
||||||
_, pageH := r.pdf.GetPageSize()
|
|
||||||
_, _, _, bm := r.pdf.GetMargins()
|
|
||||||
if r.pdf.GetY()+10 > pageH-bm {
|
|
||||||
r.pdf.AddPage()
|
|
||||||
renderHeader(true)
|
|
||||||
r.pdf.SetFont("Helvetica", "", 10)
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, col := range row {
|
|
||||||
r.pdf.CellFormat(colWidth, 10, r.tr(col), "1", 0, "L", false, 0, "")
|
|
||||||
}
|
|
||||||
r.pdf.Ln(-1)
|
|
||||||
}
|
|
||||||
r.pdf.Ln(5)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (r *IHKRenderer) RenderImage(path string, caption string) {
|
|
||||||
r.pdf.Ln(5)
|
|
||||||
|
|
||||||
info := r.pdf.GetImageInfo(path)
|
|
||||||
if info == nil {
|
|
||||||
// Try to register it first
|
|
||||||
r.pdf.RegisterImageOptions(path, fpdf.ImageOptions{ReadDpi: true})
|
|
||||||
info = r.pdf.GetImageInfo(path)
|
|
||||||
}
|
|
||||||
|
|
||||||
if info == nil {
|
|
||||||
r.pdf.CellFormat(0, 10, r.tr("[Fehler beim Laden des Bildes: "+path+"]"), "1", 1, "C", false, 0, "")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Calculate dimensions in mm
|
|
||||||
// 1 point = 0.352778 mm
|
|
||||||
imgW := info.Width() * 0.352778
|
|
||||||
imgH := info.Height() * 0.352778
|
|
||||||
|
|
||||||
maxWidth := 140.0
|
|
||||||
displayW := imgW
|
|
||||||
displayH := imgH
|
|
||||||
|
|
||||||
if displayW > maxWidth {
|
|
||||||
ratio := maxWidth / displayW
|
|
||||||
displayW = maxWidth
|
|
||||||
displayH = displayH * ratio
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check if we need a new page
|
|
||||||
_, pageH := r.pdf.GetPageSize()
|
|
||||||
_, _, _, bottomMargin := r.pdf.GetMargins()
|
|
||||||
currentY := r.pdf.GetY()
|
|
||||||
|
|
||||||
if currentY+displayH+15 > pageH-bottomMargin {
|
|
||||||
r.pdf.AddPage()
|
|
||||||
currentY = r.pdf.GetY()
|
|
||||||
}
|
|
||||||
|
|
||||||
// Center horizontally
|
|
||||||
posX := 30.0 + (maxWidth-displayW)/2
|
|
||||||
|
|
||||||
r.pdf.ImageOptions(path, posX, currentY, displayW, displayH, false, fpdf.ImageOptions{ReadDpi: true}, 0, "")
|
|
||||||
|
|
||||||
// Move Y after the image
|
|
||||||
r.pdf.SetY(currentY + displayH + 2)
|
|
||||||
|
|
||||||
if caption != "" {
|
|
||||||
r.pdf.SetFont("Helvetica", "I", 10)
|
|
||||||
r.pdf.CellFormat(0, 10, r.tr(caption), "", 1, "C", false, 0, "")
|
|
||||||
}
|
|
||||||
r.pdf.Ln(5)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (r *IHKRenderer) StartMainBody() {
|
|
||||||
r.numType = NumArabic
|
|
||||||
// The current page is the last Roman page
|
|
||||||
// So the next page will be display page 1
|
|
||||||
r.pageOffset = r.pdf.PageNo()
|
|
||||||
}
|
|
||||||
|
|
||||||
func (r *IHKRenderer) StartFrontMatter() {
|
func (r *IHKRenderer) StartFrontMatter() {
|
||||||
r.numType = NumRoman
|
r.numType = NumRoman
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *IHKRenderer) RenderBibliography() {
|
// StartMainBody switches to Arabic page numbering and captures the current PDF
|
||||||
if len(r.sources) == 0 {
|
// page as the offset so that the first main-body page displays as "1".
|
||||||
return
|
func (r *IHKRenderer) StartMainBody() {
|
||||||
}
|
r.numType = NumArabic
|
||||||
// RenderHeader(1, ...) already adds a page
|
r.pageOffset = r.pdf.PageNo()
|
||||||
r.RenderHeader(1, "Literaturverzeichnis")
|
|
||||||
r.pdf.Ln(5)
|
|
||||||
|
|
||||||
r.pdf.SetFont("Helvetica", "", 12)
|
|
||||||
|
|
||||||
// IHK Rule 2.8.1: Sort by author name
|
|
||||||
sort.Strings(r.sources)
|
|
||||||
|
|
||||||
for _, source := range r.sources {
|
|
||||||
r.pdf.MultiCell(0, 7.5, r.tr("- "+source), "", "J", false)
|
|
||||||
r.pdf.Ln(2)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (r *IHKRenderer) RenderAppendices() {
|
|
||||||
if len(r.appendices) == 0 {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// RenderHeader(1, ...) already adds a page
|
|
||||||
r.RenderHeader(1, "Anhang")
|
|
||||||
r.pdf.Ln(5)
|
|
||||||
|
|
||||||
r.pdf.SetFont("Helvetica", "B", 12)
|
|
||||||
r.pdf.CellFormat(0, 10, r.tr("Anlagenverzeichnis"), "", 1, "L", false, 0, "")
|
|
||||||
r.pdf.SetFont("Helvetica", "", 12)
|
|
||||||
for i, app := range r.appendices {
|
|
||||||
r.pdf.CellFormat(0, 10, r.tr(fmt.Sprintf("Anlage %d: %s", i+1, app.Title)), "", 1, "L", false, 0, "")
|
|
||||||
}
|
|
||||||
|
|
||||||
for i, app := range r.appendices {
|
|
||||||
r.pdf.AddPage()
|
|
||||||
r.pdf.SetFont("Helvetica", "B", 12)
|
|
||||||
r.pdf.CellFormat(0, 10, r.tr(fmt.Sprintf("Anlage %d: %s", i+1, app.Title)), "", 1, "L", false, 0, "")
|
|
||||||
r.pdf.Ln(5)
|
|
||||||
|
|
||||||
info := r.pdf.GetImageInfo(app.Path)
|
|
||||||
if info == nil {
|
|
||||||
r.pdf.RegisterImageOptions(app.Path, fpdf.ImageOptions{ReadDpi: true})
|
|
||||||
info = r.pdf.GetImageInfo(app.Path)
|
|
||||||
}
|
|
||||||
|
|
||||||
if info != nil {
|
|
||||||
imgW := info.Width() * 0.352778
|
|
||||||
imgH := info.Height() * 0.352778
|
|
||||||
|
|
||||||
maxWidth := 140.0
|
|
||||||
// Scale to full width
|
|
||||||
displayW := maxWidth
|
|
||||||
ratio := displayW / imgW
|
|
||||||
displayH := imgH * ratio
|
|
||||||
|
|
||||||
// Check if it fits on page height, if not, scale down
|
|
||||||
_, pageH := r.pdf.GetPageSize()
|
|
||||||
_, _, _, bottomMargin := r.pdf.GetMargins()
|
|
||||||
availableH := pageH - r.pdf.GetY() - bottomMargin - 10
|
|
||||||
|
|
||||||
if displayH > availableH {
|
|
||||||
ratio := availableH / displayH
|
|
||||||
displayH = availableH
|
|
||||||
displayW = displayW * ratio
|
|
||||||
}
|
|
||||||
|
|
||||||
posX := 30.0 + (140.0-displayW)/2
|
|
||||||
r.pdf.ImageOptions(app.Path, posX, r.pdf.GetY(), displayW, displayH, false, fpdf.ImageOptions{ReadDpi: true}, 0, "")
|
|
||||||
} else {
|
|
||||||
r.pdf.CellFormat(0, 10, r.tr("[Bild konnte nicht geladen werden]"), "1", 1, "C", false, 0, "")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (r *IHKRenderer) AddAppendix(titlePath string) {
|
|
||||||
parts := strings.Split(titlePath, "|")
|
|
||||||
if len(parts) < 2 {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
title := strings.TrimSpace(parts[0])
|
|
||||||
path := strings.TrimSpace(parts[1])
|
|
||||||
|
|
||||||
r.appendices = append(r.appendices, Appendix{Title: title, Path: path})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// AddSource registers a bibliography entry. Whitespace is trimmed;
|
||||||
|
// duplicate entries are silently ignored.
|
||||||
func (r *IHKRenderer) AddSource(source string) {
|
func (r *IHKRenderer) AddSource(source string) {
|
||||||
// Normalize and store
|
|
||||||
source = strings.TrimSpace(source)
|
source = strings.TrimSpace(source)
|
||||||
if source == "" {
|
if source == "" {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Prevent duplicates
|
|
||||||
for _, s := range r.sources {
|
for _, s := range r.sources {
|
||||||
if s == source {
|
if s == source {
|
||||||
return
|
return
|
||||||
@@ -527,23 +139,33 @@ 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.
|
||||||
|
func (r *IHKRenderer) AddAppendix(titlePath string) {
|
||||||
|
parts := strings.SplitN(titlePath, "|", 2)
|
||||||
|
if len(parts) < 2 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
r.appendices = append(r.appendices, Appendix{
|
||||||
|
Title: strings.TrimSpace(parts[0]),
|
||||||
|
Path: strings.TrimSpace(parts[1]),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Save writes the completed PDF to the given file path.
|
||||||
func (r *IHKRenderer) Save(filename string) error {
|
func (r *IHKRenderer) Save(filename string) error {
|
||||||
return r.pdf.OutputFileAndClose(filename)
|
return r.pdf.OutputFileAndClose(filename)
|
||||||
}
|
}
|
||||||
|
|
||||||
func toRoman(n int) string {
|
// usableWidth returns the printable line width on the current page in mm.
|
||||||
if n <= 0 {
|
func (r *IHKRenderer) usableWidth() float64 {
|
||||||
return ""
|
w, _ := r.pdf.GetPageSize()
|
||||||
}
|
lm, _, rm, _ := r.pdf.GetMargins()
|
||||||
values := []int{1000, 900, 500, 400, 100, 90, 50, 40, 10, 9, 5, 4, 1}
|
return w - lm - rm
|
||||||
symbols := []string{"M", "CM", "D", "CD", "C", "XC", "L", "XL", "X", "IX", "V", "IV", "I"}
|
}
|
||||||
|
|
||||||
res := ""
|
// isAtPageTop returns true when the Y cursor sits at or within 1 mm of the
|
||||||
for i := 0; i < len(values); i++ {
|
// top margin, meaning no body content has been placed on this page yet.
|
||||||
for n >= values[i] {
|
func (r *IHKRenderer) isAtPageTop() bool {
|
||||||
n -= values[i]
|
_, tm, _, _ := r.pdf.GetMargins()
|
||||||
res += symbols[i]
|
return r.pdf.GetY() <= tm+1.0
|
||||||
}
|
|
||||||
}
|
|
||||||
return res
|
|
||||||
}
|
}
|
||||||
|
|||||||
+167
@@ -0,0 +1,167 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import "strconv"
|
||||||
|
|
||||||
|
// TOCItem is one entry in the Inhaltsverzeichnis.
|
||||||
|
type TOCItem struct {
|
||||||
|
Level int
|
||||||
|
Title string
|
||||||
|
PageStr string // formatted page number (Roman or Arabic)
|
||||||
|
}
|
||||||
|
|
||||||
|
// TableItem is one entry in the Tabellenverzeichnis.
|
||||||
|
type TableItem struct {
|
||||||
|
Title string
|
||||||
|
PageStr string
|
||||||
|
}
|
||||||
|
|
||||||
|
// FigureItem is one entry in the Abbildungsverzeichnis.
|
||||||
|
type FigureItem struct {
|
||||||
|
Title string
|
||||||
|
PageStr string
|
||||||
|
}
|
||||||
|
|
||||||
|
// dirEntry is the internal representation used by renderDirEntries.
|
||||||
|
type dirEntry struct {
|
||||||
|
indent int // indentation level (0 = none)
|
||||||
|
title string // translated title text
|
||||||
|
pageStr string // page number string
|
||||||
|
}
|
||||||
|
|
||||||
|
// RecordHeader adds a heading to the TOC with the appropriate page number.
|
||||||
|
// Front-matter headings use Roman numerals; main-body headings use Arabic.
|
||||||
|
// Headings on the title page (NumNone) are not recorded.
|
||||||
|
func (r *IHKRenderer) RecordHeader(level int, title string) {
|
||||||
|
var pageStr string
|
||||||
|
switch r.numType {
|
||||||
|
case NumRoman:
|
||||||
|
pageStr = toRoman(r.pdf.PageNo())
|
||||||
|
case NumArabic:
|
||||||
|
dp := r.pdf.PageNo() - r.pageOffset
|
||||||
|
if dp <= 0 {
|
||||||
|
dp = 1
|
||||||
|
}
|
||||||
|
pageStr = strconv.Itoa(dp)
|
||||||
|
default:
|
||||||
|
return
|
||||||
|
}
|
||||||
|
r.tocItems = append(r.tocItems, TOCItem{Level: level, Title: title, PageStr: pageStr})
|
||||||
|
}
|
||||||
|
|
||||||
|
// RecordTable adds an entry to the Tabellenverzeichnis.
|
||||||
|
func (r *IHKRenderer) RecordTable(title string) {
|
||||||
|
dp := r.pdf.PageNo() - r.pageOffset
|
||||||
|
if dp <= 0 {
|
||||||
|
dp = 1
|
||||||
|
}
|
||||||
|
r.tableItems = append(r.tableItems, TableItem{Title: title, PageStr: strconv.Itoa(dp)})
|
||||||
|
}
|
||||||
|
|
||||||
|
// RecordFigure adds an entry to the Abbildungsverzeichnis.
|
||||||
|
func (r *IHKRenderer) RecordFigure(title string) {
|
||||||
|
dp := r.pdf.PageNo() - r.pageOffset
|
||||||
|
if dp <= 0 {
|
||||||
|
dp = 1
|
||||||
|
}
|
||||||
|
r.figureItems = append(r.figureItems, FigureItem{Title: title, PageStr: strconv.Itoa(dp)})
|
||||||
|
}
|
||||||
|
|
||||||
|
// RenderTOC renders the Inhaltsverzeichnis on a new Roman-numbered page.
|
||||||
|
// In pass 1 the list is empty (no tocItems yet); pass 2 fills it in.
|
||||||
|
// Note: if the TOC spans more pages than in pass 1, subsequent page numbers
|
||||||
|
// will be off by the difference — an inherent two-pass limitation.
|
||||||
|
func (r *IHKRenderer) RenderTOC() {
|
||||||
|
r.numType = NumRoman
|
||||||
|
r.pdf.AddPage()
|
||||||
|
|
||||||
|
r.pdf.SetFont("Helvetica", "B", dinFontHeading)
|
||||||
|
r.pdf.CellFormat(0, dinLineHtHeading+4, r.tr("Inhaltsverzeichnis"), "", 1, "L", false, 0, "")
|
||||||
|
r.pdf.Ln(dinSpaceAfterHeading)
|
||||||
|
|
||||||
|
if len(r.tocItems) == 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Column header row
|
||||||
|
uw := r.usableWidth()
|
||||||
|
r.pdf.SetFont("Helvetica", "B", dinFontBody)
|
||||||
|
r.pdf.CellFormat(uw-20, dinLineHtBody, r.tr("Inhalt"), "", 0, "L", false, 0, "")
|
||||||
|
r.pdf.CellFormat(20, dinLineHtBody, r.tr("Seite"), "", 1, "R", false, 0, "")
|
||||||
|
r.pdf.Ln(2)
|
||||||
|
|
||||||
|
entries := make([]dirEntry, len(r.tocItems))
|
||||||
|
for i, item := range r.tocItems {
|
||||||
|
entries[i] = dirEntry{
|
||||||
|
indent: item.Level - 1,
|
||||||
|
title: item.Title,
|
||||||
|
pageStr: item.PageStr,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
r.renderDirEntries(entries)
|
||||||
|
}
|
||||||
|
|
||||||
|
// RenderListOfTables renders the Tabellenverzeichnis if any tables were recorded.
|
||||||
|
func (r *IHKRenderer) RenderListOfTables() {
|
||||||
|
if len(r.tableItems) == 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
r.pdf.AddPage()
|
||||||
|
r.pdf.SetFont("Helvetica", "B", dinFontHeading)
|
||||||
|
r.pdf.CellFormat(0, dinLineHtHeading+4, r.tr("Tabellenverzeichnis"), "", 1, "L", false, 0, "")
|
||||||
|
r.pdf.Ln(dinSpaceAfterHeading)
|
||||||
|
|
||||||
|
entries := make([]dirEntry, len(r.tableItems))
|
||||||
|
for i, item := range r.tableItems {
|
||||||
|
entries[i] = dirEntry{indent: 0, title: item.Title, pageStr: item.PageStr}
|
||||||
|
}
|
||||||
|
r.renderDirEntries(entries)
|
||||||
|
}
|
||||||
|
|
||||||
|
// RenderListOfFigures renders the Abbildungsverzeichnis if any figures were recorded.
|
||||||
|
func (r *IHKRenderer) RenderListOfFigures() {
|
||||||
|
if len(r.figureItems) == 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
r.pdf.AddPage()
|
||||||
|
r.pdf.SetFont("Helvetica", "B", dinFontHeading)
|
||||||
|
r.pdf.CellFormat(0, dinLineHtHeading+4, r.tr("Abbildungsverzeichnis"), "", 1, "L", false, 0, "")
|
||||||
|
r.pdf.Ln(dinSpaceAfterHeading)
|
||||||
|
|
||||||
|
entries := make([]dirEntry, len(r.figureItems))
|
||||||
|
for i, item := range r.figureItems {
|
||||||
|
entries[i] = dirEntry{indent: 0, title: item.Title, pageStr: item.PageStr}
|
||||||
|
}
|
||||||
|
r.renderDirEntries(entries)
|
||||||
|
}
|
||||||
|
|
||||||
|
// renderDirEntries renders a list of title/page entries with dot leaders,
|
||||||
|
// per DIN 5008 Inhaltsverzeichnis layout.
|
||||||
|
func (r *IHKRenderer) renderDirEntries(entries []dirEntry) {
|
||||||
|
uw := r.usableWidth()
|
||||||
|
lm, _, _, _ := r.pdf.GetMargins()
|
||||||
|
r.pdf.SetFont("Helvetica", "", dinFontBody)
|
||||||
|
|
||||||
|
for _, e := range entries {
|
||||||
|
indent := float64(e.indent * 8)
|
||||||
|
r.pdf.SetX(lm + indent)
|
||||||
|
|
||||||
|
title := r.tr(e.title)
|
||||||
|
pageStr := e.pageStr
|
||||||
|
tw := r.pdf.GetStringWidth(title)
|
||||||
|
pw := r.pdf.GetStringWidth(pageStr)
|
||||||
|
avail := uw - indent - tw - pw - 6
|
||||||
|
|
||||||
|
r.pdf.CellFormat(tw+2, dinLineHtBody, title, "", 0, "L", false, 0, "")
|
||||||
|
if avail > 0 {
|
||||||
|
dots := ""
|
||||||
|
dw := r.pdf.GetStringWidth(".")
|
||||||
|
if dw > 0 {
|
||||||
|
for float64(len([]rune(dots)))*dw < avail {
|
||||||
|
dots += "."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
r.pdf.CellFormat(avail, dinLineHtBody, dots, "", 0, "L", false, 0, "")
|
||||||
|
}
|
||||||
|
r.pdf.CellFormat(pw+4, dinLineHtBody, pageStr, "", 1, "R", false, 0, "")
|
||||||
|
}
|
||||||
|
}
|
||||||
Binary file not shown.
@@ -7,78 +7,104 @@ student:
|
|||||||
project:
|
project:
|
||||||
title: "Entwicklung eines Markdown-zu-IHK-Konverters"
|
title: "Entwicklung eines Markdown-zu-IHK-Konverters"
|
||||||
subtitle: "Projektdokumentation zur Abschlussprüfung"
|
subtitle: "Projektdokumentation zur Abschlussprüfung"
|
||||||
period: "Frühjahr 2026"
|
period: "Sommer 2026"
|
||||||
|
abbreviations:
|
||||||
|
- abbr: "IHK"
|
||||||
|
meaning: "Industrie- und Handelskammer"
|
||||||
|
- abbr: "PDF"
|
||||||
|
meaning: "Portable Document Format"
|
||||||
|
- abbr: "API"
|
||||||
|
meaning: "Application Programming Interface"
|
||||||
|
- abbr: "AST"
|
||||||
|
meaning: "Abstract Syntax Tree"
|
||||||
|
- abbr: "DIN"
|
||||||
|
meaning: "Deutsches Institut für Normung"
|
||||||
|
glossary:
|
||||||
|
- term: "Goldmark"
|
||||||
|
definition: "Ein in Go geschriebener Markdown-Parser, der den CommonMark-Standard implementiert."
|
||||||
|
- term: "FPDF"
|
||||||
|
definition: "Eine Go-Bibliothek zur Erzeugung von PDF-Dokumenten ohne externe Abhängigkeiten."
|
||||||
|
- term: "Kroki"
|
||||||
|
definition: "Ein Webdienst, der verschiedene Diagramm-Beschreibungssprachen (Mermaid, PlantUML u.a.) in Bilder umwandelt."
|
||||||
---
|
---
|
||||||
|
|
||||||
# Vorwort
|
# Vorwort
|
||||||
|
|
||||||
Dieses Projekt entstand im Rahmen der Abschlussprüfung...
|
Dieses Projekt entstand im Rahmen der Abschlussprüfung zum Fachinformatiker
|
||||||
|
Fachrichtung Anwendungsentwicklung. Es soll zeigen, dass technische Dokumentationen
|
||||||
|
effizient und normgerecht erstellt werden können.
|
||||||
|
|
||||||
# 1. Problemstellung
|
# 1. Problemstellung
|
||||||
|
|
||||||
## 1.1 Ausgangslage
|
## 1.1 Ausgangslage
|
||||||
Aktuell müssen IHK-Dokumentationen mühsam in Word formatiert werden, was fehleranfällig ist und viel Zeit kostet. Besonders schwierig ist die Einhaltung der Formvorgaben für Umlaute wie Ä, Ö, Ü und das Eszett ß.
|
|
||||||
|
Aktuell müssen IHK-Dokumentationen mühsam in Word formatiert werden, was
|
||||||
|
fehleranfällig ist und viel Zeit kostet. Besonders die Einhaltung der
|
||||||
|
Formvorgaben – Schriftgröße, Zeilenabstand und Seitenränder – erfordert
|
||||||
|
manuelle Sorgfalt bei jedem Absatz.
|
||||||
|
|
||||||
## 1.2 Zielsetzung
|
## 1.2 Zielsetzung
|
||||||
Ziel ist ein Go-Tool, das Markdown in PDF umwandelt und dabei alle formalen Anforderungen der IHK Chemnitz erfüllt. Es soll die Prüfungsvorbereitung erleichtern und die Qualität der Dokumente erhöhen.
|
|
||||||
|
Ziel ist ein Go-Tool, das **Markdown** in PDF umwandelt und dabei alle
|
||||||
|
formalen Anforderungen der IHK Chemnitz erfüllt. Es soll:
|
||||||
|
|
||||||
|
- die Prüfungsvorbereitung erleichtern,
|
||||||
|
- die Qualität der Dokumente *einheitlich* sicherstellen und
|
||||||
|
- den Prozess vollständig automatisieren.
|
||||||
|
|
||||||
# 2. Projektablauf
|
# 2. Projektablauf
|
||||||
|
|
||||||
## 2.1 Planung
|
## 2.1 Planung
|
||||||
Die Planung umfasst die Analyse der IHK-Vorgaben und das Design der Software-Architektur.
|
|
||||||
|
|
||||||
### Architektur-Übersicht (Mermaid)
|
Die Planung umfasst die Analyse der IHK-Vorgaben und das Design der
|
||||||
|
Software-Architektur. Der Projektzeitraum beträgt maximal 80 Stunden
|
||||||
|
gemäß Ausbildungsverordnung.
|
||||||
|
|
||||||
|
### Architektur-Übersicht
|
||||||
|
|
||||||
```mermaid
|
```mermaid
|
||||||
graph TD
|
graph TD
|
||||||
A[Markdown] --> B(Go Parser)
|
A[Markdown] --> B(Go Parser)
|
||||||
B --> C{Metadata?}
|
B --> C{Metadaten?}
|
||||||
C -->|Ja| D[Config]
|
C -->|Ja| D[Config]
|
||||||
C -->|Nein| E[Default]
|
C -->|Nein| E[Standard]
|
||||||
D --> F[PDF Renderer]
|
D --> F[PDF Renderer]
|
||||||
E --> F
|
E --> F
|
||||||
F --> G[IHK PDF]
|
F --> G[IHK-konformes PDF]
|
||||||
```
|
|
||||||
|
|
||||||
### Klassen-Diagramm (PlantUML)
|
|
||||||
```puml
|
|
||||||
@startuml
|
|
||||||
class IHKRenderer {
|
|
||||||
+RenderTOC()
|
|
||||||
+RenderBibliography()
|
|
||||||
+RenderAppendices()
|
|
||||||
}
|
|
||||||
IHKRenderer o-- TOCItem
|
|
||||||
IHKRenderer o-- Appendix
|
|
||||||
@enduml
|
|
||||||
```
|
```
|
||||||
|
|
||||||
## 2.2 Realisierung
|
## 2.2 Realisierung
|
||||||
Die Realisierung erfolgt in Go unter Verwendung von `goldmark` und `fpdf`.
|
|
||||||
|
|
||||||
| Tool | Zweck |
|
Die Realisierung erfolgt in Go unter Verwendung von `goldmark` und `fpdf`.
|
||||||
|------|-------|
|
Der Konverter verarbeitet die Markdown-Datei in zwei Durchläufen, um das
|
||||||
| Go | Programmiersprache |
|
Inhaltsverzeichnis korrekt mit Seitenangaben zu befüllen.
|
||||||
| Goldmark | Markdown Parser |
|
|
||||||
| FPDF | PDF Renderer |
|
| Werkzeug | Zweck | Version |
|
||||||
|
|----------|-------|---------|
|
||||||
|
| Go | Programmiersprache | 1.22+ |
|
||||||
|
| Goldmark | Markdown-Parser (AST) | v1.8 |
|
||||||
|
| FPDF | PDF-Erzeugung | v0.9 |
|
||||||
|
| Kroki | Diagramm-Rendering | online |
|
||||||
|
|
||||||
@Quelle: Goldmark Documentation, https://github.com/yuin/goldmark, 2024
|
@Quelle: Goldmark Documentation, https://github.com/yuin/goldmark, 2024
|
||||||
> Quelle: Go-PDF/Fpdf Documentation, https://github.com/go-pdf/fpdf, 2025
|
@Quelle: Go-PDF/Fpdf Documentation, https://github.com/go-pdf/fpdf, 2025
|
||||||
|
|
||||||
|
## 2.3 Test und Qualitätssicherung
|
||||||
|
|
||||||
|
Das Tool wurde anhand eines Musterdokuments getestet. Folgende Kriterien
|
||||||
|
wurden geprüft:
|
||||||
|
|
||||||
|
1. Korrekte Seitenränder gemäß DIN 5008
|
||||||
|
2. Seitennummerierung (römisch im Vorspann, arabisch im Textteil)
|
||||||
|
3. Schriftart Helvetica, 12 Punkt, 1½-zeilig
|
||||||
|
4. Automatische Abbildungs- und Tabellenverzeichnisse
|
||||||
|
|
||||||
# 3. Zusammenfassung
|
# 3. Zusammenfassung
|
||||||
|
|
||||||
Das Tool ermöglicht eine effiziente Erstellung von Dokumentationen unter Einhaltung aller Formatvorgaben.
|
Das Tool ermöglicht eine effiziente Erstellung von IHK-Dokumentationen unter
|
||||||
|
Einhaltung **aller** Formatvorgaben. Durch die Trennung von Inhalt (Markdown)
|
||||||
|
und Formatierung (Go-Renderer) ist eine konsistente Ausgabe garantiert.
|
||||||
|
|
||||||
@Quelle: IHK Chemnitz, Hinweise zur Erarbeitung der Dokumentation, 2020
|
@Quelle: IHK Chemnitz, Hinweise zur Erarbeitung der Dokumentation über die Projektarbeit, 2020
|
||||||
|
|
||||||
@Anhang: Architektur Diagramm | test.png
|
@Anhang: Architektur-Diagramm Übersicht | test.png
|
||||||
@Anhang: Datenbank Schema | test.png
|
|
||||||
|
|
||||||
@AnhangUML: Sequenzdiagramm Generierung
|
|
||||||
```puml
|
|
||||||
@startuml
|
|
||||||
User -> Generator: Markdown
|
|
||||||
Generator -> Kroki: Code
|
|
||||||
Kroki -> Generator: PNG
|
|
||||||
Generator -> PDF: Embed
|
|
||||||
@enduml
|
|
||||||
```
|
|
||||||
|
|||||||
@@ -1,98 +0,0 @@
|
|||||||
Hinweise zur Erarbeitung der Dokumentation über die Projektarbeit
|
|
||||||
für die Abschlussprüfung in IT-Berufen
|
|
||||||
|
|
||||||
1 VORBEMERKUNGEN
|
|
||||||
In einer fachübergreifenden Projektarbeit soll nachgewiesen werden,
|
|
||||||
eine komplexe Problemstellung der betrieblichen Praxis erfassen,
|
|
||||||
darstellen, beurteilen und lösen zu können. Der Rahmen der The-
|
|
||||||
menstellungen ergibt sich aus den Prüfungsanforderungen. Sie muss
|
|
||||||
die betriebliche Praxis des Prüfungsteilnehmers berücksichtigen. Bei
|
|
||||||
der Bearbeitung Ihrer Projektarbeit (PJA) übernehmen Sie die Rolle
|
|
||||||
eines Mitarbeiters im IT-Bereich, der nicht nur Lösungsvorschläge in
|
|
||||||
diesem Umfeld erarbeitet, sondern diese auch gegenüber einem
|
|
||||||
Fachgremium (das auch die Geschäftsleitung oder der Kunde sein
|
|
||||||
kann) zu vertreten hat (Präsentation/ Fachgespräch). Dabei sollen
|
|
||||||
Sie nicht nur Ihre neu hinzugewonnenen technischen und kaufmän-
|
|
||||||
nischen Kenntnisse anwenden, sondern auch durch Ihre Energie,
|
|
||||||
Kreativität und Ideen unter Beweis stellen, dass Sie über die erfor-
|
|
||||||
derliche Handlungskompetenz verfügen, um Ihr Team effektiver und
|
|
||||||
das gesamte Unternehmen wettbewerbsfähiger zu machen.
|
|
||||||
|
|
||||||
Für die Gestaltung der PJA gibt es inzwischen reichlich Informations-
|
|
||||||
schriften und Handreichungen. Aus den langjährigen Prüfungserfah-
|
|
||||||
rungen der Prüfungsausschüsse der IHK Chemnitz ergeben sich As-
|
|
||||||
pekte, aus denen sich einige Ratschläge ableiten.
|
|
||||||
|
|
||||||
Mit diesen Hinweisen will die IHK Chemnitz auf diese Belange ein-
|
|
||||||
gehen und damit zur allgemeinen Information der Prüfungsteilneh-
|
|
||||||
mer beitragen.
|
|
||||||
2 AUFBAU EINER PROJEKTARBEIT
|
|
||||||
2.1 BESTANDTEILE
|
|
||||||
Übersicht Eine PJA sollte sich aus nachfolgenden Elementen zusam-
|
|
||||||
mensetzen:
|
|
||||||
|
|
||||||
1 Titelblatt1
|
|
||||||
|
|
||||||
2 Inhaltsverzeichnis2
|
|
||||||
|
|
||||||
3 Ggf. Abkürzungsverzeichnis2
|
|
||||||
|
|
||||||
4 Vorwort/ Einleitung2
|
|
||||||
Textteil (Problemstellung, betriebliche/ kundengerechte
|
|
||||||
5
|
|
||||||
Dokumentation der Ergebnisse, Zusammenfassung inkl.
|
|
||||||
Zeitplanung)3
|
|
||||||
6 Literaturverzeichnis3
|
|
||||||
|
|
||||||
7 Ggf. Abbildungs-/ Tabellenverzeichnis3
|
|
||||||
|
|
||||||
8 Ggf. Anlagenverzeichnis3
|
|
||||||
|
|
||||||
9 Ggf. Glossar3
|
|
||||||
|
|
||||||
10 Ggf. Stichwortverzeichnis3
|
|
||||||
|
|
||||||
11 Erklärung4
|
|
||||||
|
|
||||||
Tab. 1: Komponenten einer Projektarbeit
|
|
||||||
2.2 TITELBLATT
|
|
||||||
Übersicht Das Titelblatt enthält alle für diesen Prüfungsteil erforderlichen Da-
|
|
||||||
ten.
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
V orl ag e
|
|
||||||
|
|
||||||
Abb. 1: Tit el bla tt der Proj ekt ar b eit
|
|
||||||
Abschlussprüfung zum
|
|
||||||
|
|
||||||
|
|
||||||
Projektarbeit von
|
|
||||||
|
|
||||||
|
|
||||||
Max Mustermann
|
|
||||||
|
|
||||||
|
|
||||||
Thema der Projektarbeit
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
Prüfungsperiode:
|
|
||||||
|
|
||||||
Ausbildungsbetrieb
|
|
||||||
(bzw. Praktikumsbetrieb):
|
|
||||||
|
|
||||||
Projektbetreuer
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
2.3 INHALTSVERZEICHNIS
|
|
||||||
Inhalt Das Inhaltsverzeichnis zeigt die Gliederung der PJA und muss ent-
|
|
||||||
sprechend der DIN 5008 durchnummeriert und mit Seitenangaben
|
|
||||||
versehen werden. Es sollte das Thema differenziert aufschlüsseln,
|
|
||||||
logisch aufgebaut, deutlich formuliert und übersichtlich dargestellt
|
|
||||||
werden.
|
|
||||||
|
|
||||||
Reference in New Issue
Block a user