449 lines
15 KiB
Go
449 lines
15 KiB
Go
package main
|
||
|
||
import (
|
||
"fmt"
|
||
"image"
|
||
"image/draw"
|
||
_ "image/jpeg"
|
||
"image/png"
|
||
"log"
|
||
"os"
|
||
"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. Annexes marked
|
||
// Landscape=true are placed on a landscape A4 page with 15 mm symmetric margins.
|
||
func (r *IHKRenderer) RenderAppendices() {
|
||
if len(r.appendices) == 0 {
|
||
return
|
||
}
|
||
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 %s: %s", toRoman(i+1), app.Title)), "", 1, "L", false, 0, "")
|
||
}
|
||
|
||
// Save portrait dimensions and standard margins for post-landscape restoration.
|
||
portraitW, portraitH := r.pdf.GetPageSize()
|
||
lm, tm, rm, _ := r.pdf.GetMargins()
|
||
|
||
const diagMargin = 15.0
|
||
|
||
// Individual annex pages
|
||
for i, app := range r.appendices {
|
||
if app.Landscape {
|
||
r.pdf.SetMargins(diagMargin, diagMargin, diagMargin)
|
||
r.pdf.SetAutoPageBreak(true, diagMargin)
|
||
// portraitH > portraitW → {Wd: portraitH, Ht: portraitW} = landscape 297×210.
|
||
r.pdf.AddPageFormat("L", fpdf.SizeType{Wd: portraitH, Ht: portraitW})
|
||
} else {
|
||
r.pdf.AddPage()
|
||
}
|
||
|
||
r.pdf.SetFont("Helvetica", "B", dinFontBody)
|
||
r.pdf.CellFormat(0, dinLineHtBody,
|
||
r.tr(fmt.Sprintf("Anlage %s: %s", toRoman(i+1), app.Title)), "", 1, "L", false, 0, "")
|
||
r.pdf.Ln(dinSpaceAfterHeading)
|
||
|
||
switch app.Kind {
|
||
case AppendixKindImage:
|
||
if app.Rotated {
|
||
r.renderAppendixImageRotated(app.Path)
|
||
} else {
|
||
r.renderAppendixImage(app.Path)
|
||
}
|
||
case AppendixKindTable:
|
||
r.renderTableBody(app.TableData, "")
|
||
case AppendixKindCode:
|
||
r.RenderCodeBlock(app.Lang, app.Code)
|
||
}
|
||
|
||
if app.Landscape {
|
||
// Restore standard margins so the next AddPage() returns to portrait.
|
||
r.pdf.SetMargins(lm, tm, rm)
|
||
r.pdf.SetAutoPageBreak(true, dinMarginBottom)
|
||
}
|
||
}
|
||
}
|
||
|
||
// 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, "")
|
||
}
|
||
|
||
// renderAppendixImageRotated places an image on a portrait page rotated 90° CW.
|
||
// The image is pre-rotated in memory so that a wide (landscape-ratio) diagram
|
||
// fills the page height. The reader tilts the page 90° CW to read it normally.
|
||
func (r *IHKRenderer) renderAppendixImageRotated(path string) {
|
||
rotatedPath, err := rotateImageCW(path)
|
||
if err != nil {
|
||
log.Printf("warning: could not rotate image %q: %v — falling back to normal", path, err)
|
||
r.renderAppendixImage(path)
|
||
return
|
||
}
|
||
|
||
r.renderAppendixImageWithPath(rotatedPath)
|
||
}
|
||
|
||
// rotateImageCW decodes a PNG/JPEG from disk, rotates it 90° clockwise, writes
|
||
// the result to a temp file, and returns the temp file path.
|
||
// The caller owns the temp file (it is left on disk; fpdf reads it lazily).
|
||
func rotateImageCW(src string) (string, error) {
|
||
f, err := os.Open(src)
|
||
if err != nil {
|
||
return "", err
|
||
}
|
||
defer f.Close()
|
||
|
||
img, _, err := image.Decode(f)
|
||
if err != nil {
|
||
return "", err
|
||
}
|
||
|
||
b := img.Bounds()
|
||
// 90° CW: new width = old height, new height = old width.
|
||
rotated := image.NewRGBA(image.Rect(0, 0, b.Max.Y-b.Min.Y, b.Max.X-b.Min.X))
|
||
draw.Draw(rotated, rotated.Bounds(), image.White, image.Point{}, draw.Src)
|
||
for y := b.Min.Y; y < b.Max.Y; y++ {
|
||
for x := b.Min.X; x < b.Max.X; x++ {
|
||
// 90° CW: new(newW-1-y, x) = old(x, y)
|
||
rotated.Set(b.Max.Y-1-y, x-b.Min.X, img.At(x, y))
|
||
}
|
||
}
|
||
|
||
tmp, err := os.CreateTemp("", "ihk_rotated_*.png")
|
||
if err != nil {
|
||
return "", err
|
||
}
|
||
defer tmp.Close()
|
||
|
||
if err := png.Encode(tmp, rotated); err != nil {
|
||
os.Remove(tmp.Name())
|
||
return "", err
|
||
}
|
||
return tmp.Name(), nil
|
||
}
|
||
|
||
// renderAppendixImageWithPath is identical to renderAppendixImage but accepts
|
||
// an arbitrary path (used for pre-rotated temp files).
|
||
func (r *IHKRenderer) renderAppendixImageWithPath(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
|
||
|
||
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, "")
|
||
r.pdf.SetY(r.pdf.GetY() + displayH + 2)
|
||
}
|
||
|
||
// 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)
|
||
}
|
||
}
|
||
|
||
// RenderLandscapeDiagram renders an image on a dedicated landscape A4 page,
|
||
// scaled to fill the full printable area. A figure caption is added below the
|
||
// image and recorded in the Abbildungsverzeichnis.
|
||
//
|
||
// The page uses symmetric 15 mm margins (no Korrekturrand — examiners do not
|
||
// mark up diagram pages). After rendering, a fresh portrait page is opened so
|
||
// subsequent content continues in the correct orientation.
|
||
func (r *IHKRenderer) RenderLandscapeDiagram(path, caption string) {
|
||
// Current dimensions before the landscape page (portrait A4: 210 × 297 mm).
|
||
pw, ph := r.pdf.GetPageSize()
|
||
lm, tm, rm, _ := r.pdf.GetMargins()
|
||
|
||
const diagMargin = 15.0
|
||
|
||
// Apply symmetric margins before adding the landscape page so the page
|
||
// inherits them. The Korrekturrand (40 mm) is not needed on a diagram page.
|
||
r.pdf.SetMargins(diagMargin, diagMargin, diagMargin)
|
||
r.pdf.SetAutoPageBreak(true, diagMargin)
|
||
// ph > pw for portrait A4 → {Wd: ph, Ht: pw} = landscape 297 × 210 mm.
|
||
r.pdf.AddPageFormat("L", fpdf.SizeType{Wd: ph, Ht: pw})
|
||
|
||
captionH := dinLineHtCaption + 4
|
||
availW := ph - 2*diagMargin // 297 − 30 = 267 mm
|
||
availH := pw - 2*diagMargin - captionH - 2 // 210 − 30 − ~10 ≈ 170 mm
|
||
|
||
info := r.ensureImageRegistered(path)
|
||
if info == nil {
|
||
log.Printf("warning: landscape diagram image not found: %q", path)
|
||
r.pdf.SetFont("Helvetica", "I", dinFontCaption)
|
||
r.pdf.CellFormat(0, dinLineHtCaption,
|
||
r.tr("[Bild nicht gefunden: "+path+"]"), "1", 1, "C", false, 0, "")
|
||
} else {
|
||
imgW := info.Width() * ptToMM
|
||
imgH := info.Height() * ptToMM
|
||
|
||
// Scale to fill available width, clamp to available height.
|
||
displayW := availW
|
||
displayH := imgH * (displayW / imgW)
|
||
if displayH > availH {
|
||
displayH = availH
|
||
displayW = imgW * (displayH / imgH)
|
||
if displayW > availW {
|
||
displayW = availW
|
||
displayH = imgH * (displayW / imgW)
|
||
}
|
||
}
|
||
|
||
posX := diagMargin + (availW-displayW)/2
|
||
posY := diagMargin
|
||
|
||
r.pdf.ImageOptions(path, posX, posY, displayW, displayH, false,
|
||
fpdf.ImageOptions{ReadDpi: true}, 0, "")
|
||
r.pdf.SetY(posY + displayH + 2)
|
||
|
||
r.figureCount++
|
||
label := fmt.Sprintf("Abb. %d", r.figureCount)
|
||
if caption != "" {
|
||
label += ": " + caption
|
||
}
|
||
r.RecordFigure(label)
|
||
|
||
r.pdf.SetFont("Helvetica", "I", dinFontCaption)
|
||
r.pdf.CellFormat(0, captionH, r.tr(label), "", 1, "C", false, 0, "")
|
||
}
|
||
|
||
// Restore portrait margins and open a fresh portrait page.
|
||
// AddPage() always uses the default orientation ("P") set at construction,
|
||
// so subsequent content is guaranteed to be in portrait.
|
||
r.pdf.SetMargins(lm, tm, rm)
|
||
r.pdf.SetAutoPageBreak(true, dinMarginBottom)
|
||
r.pdf.AddPage()
|
||
}
|
||
|
||
// ensureImageRegistered registers an image with fpdf if not already known
|
||
// and returns its metadata. Returns nil if the image cannot be loaded.
|
||
func (r *IHKRenderer) ensureImageRegistered(path string) *fpdf.ImageInfoType {
|
||
info := r.pdf.GetImageInfo(path)
|
||
if info == nil {
|
||
r.pdf.RegisterImageOptions(path, fpdf.ImageOptions{ReadDpi: true})
|
||
info = r.pdf.GetImageInfo(path)
|
||
}
|
||
if info == nil {
|
||
log.Printf("warning: could not load image %q", path)
|
||
}
|
||
return info
|
||
}
|
||
|
||
// ptToMM converts PDF points to millimetres (1 pt = 25.4 / 72 mm).
|
||
const ptToMM = 25.4 / 72.0
|