Add support for rotated appendices: implement 90° CCW image rotation for portrait pages, enhance table rendering logic, and update diagram handling directives.

This commit is contained in:
Sebastian Unterschütz
2026-05-17 23:41:45 +02:00
parent 427372b82b
commit d6b3854681
9 changed files with 318 additions and 22 deletions
Generated
+2
View File
@@ -2,5 +2,7 @@
<project version="4"> <project version="4">
<component name="VcsDirectoryMappings"> <component name="VcsDirectoryMappings">
<mapping directory="$PROJECT_DIR$" vcs="Git" /> <mapping directory="$PROJECT_DIR$" vcs="Git" />
<mapping directory="$PROJECT_DIR$/realProjektarbeit" vcs="Git" />
<mapping directory="$PROJECT_DIR$/realProjektarbeit/Code" vcs="Git" />
</component> </component>
</project> </project>
+8 -3
View File
@@ -4,12 +4,16 @@
<option name="autoReloadType" value="ALL" /> <option name="autoReloadType" value="ALL" />
</component> </component>
<component name="ChangeListManager"> <component name="ChangeListManager">
<list default="true" id="c64f46d5-641a-468c-8fc1-94edec1f2deb" name="Changes" comment="Add support for code appendices: enable code blocks with language labels, line-number gutter, and directive-based integration."> <list default="true" id="c64f46d5-641a-468c-8fc1-94edec1f2deb" name="Changes" comment="Refactor table rendering: replace plain text with `InlineSpan` for rich text support, update row preparation, and improve PDF formatting logic.">
<change afterPath="$PROJECT_DIR$/real_report.md" afterDir="false" /> <change afterPath="$PROJECT_DIR$/real_report.md" afterDir="false" />
<change beforePath="$PROJECT_DIR$/.idea/workspace.xml" beforeDir="false" afterPath="$PROJECT_DIR$/.idea/workspace.xml" afterDir="false" />
<change beforePath="$PROJECT_DIR$/MarkdownToIHKChemnits" beforeDir="false" afterPath="$PROJECT_DIR$/MarkdownToIHKChemnits" afterDir="false" /> <change beforePath="$PROJECT_DIR$/MarkdownToIHKChemnits" beforeDir="false" afterPath="$PROJECT_DIR$/MarkdownToIHKChemnits" afterDir="false" />
<change beforePath="$PROJECT_DIR$/go.sum" beforeDir="false" afterPath="$PROJECT_DIR$/go.sum" afterDir="false" />
<change beforePath="$PROJECT_DIR$/markdown_parser.go" beforeDir="false" afterPath="$PROJECT_DIR$/markdown_parser.go" afterDir="false" /> <change beforePath="$PROJECT_DIR$/markdown_parser.go" beforeDir="false" afterPath="$PROJECT_DIR$/markdown_parser.go" afterDir="false" />
<change beforePath="$PROJECT_DIR$/pdf_content.go" beforeDir="false" afterPath="$PROJECT_DIR$/pdf_content.go" afterDir="false" /> <change beforePath="$PROJECT_DIR$/pdf_content.go" beforeDir="false" afterPath="$PROJECT_DIR$/pdf_content.go" afterDir="false" />
<change beforePath="$PROJECT_DIR$/pdf_pages.go" beforeDir="false" afterPath="$PROJECT_DIR$/pdf_pages.go" afterDir="false" />
<change beforePath="$PROJECT_DIR$/pdf_renderer.go" beforeDir="false" afterPath="$PROJECT_DIR$/pdf_renderer.go" afterDir="false" /> <change beforePath="$PROJECT_DIR$/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" />
</list> </list>
<option name="SHOW_DIALOG" value="false" /> <option name="SHOW_DIALOG" value="false" />
<option name="HIGHLIGHT_CONFLICTS" value="true" /> <option name="HIGHLIGHT_CONFLICTS" value="true" />
@@ -17,7 +21,7 @@
<option name="LAST_RESOLUTION" value="IGNORE" /> <option name="LAST_RESOLUTION" value="IGNORE" />
</component> </component>
<component name="EmbeddingIndexingInfo"> <component name="EmbeddingIndexingInfo">
<option name="cachedIndexableFilesCount" value="23" /> <option name="cachedIndexableFilesCount" value="25" />
<option name="fileBasedEmbeddingIndicesEnabled" value="true" /> <option name="fileBasedEmbeddingIndicesEnabled" value="true" />
</component> </component>
<component name="GOROOT" url="file:///usr/lib/go" /> <component name="GOROOT" url="file:///usr/lib/go" />
@@ -101,7 +105,8 @@
<MESSAGE value="Add support for appendices: landscape diagrams, tables, and images; implement Kroki URL configurability; enhance directive parsing logic." /> <MESSAGE value="Add support for appendices: landscape diagrams, tables, and images; implement Kroki URL configurability; enhance directive parsing logic." />
<MESSAGE value="Update MarkdownToIHKChemnitz: modify core functionality for improved PDF rendering" /> <MESSAGE value="Update MarkdownToIHKChemnitz: modify core functionality for improved PDF rendering" />
<MESSAGE value="Add support for code appendices: enable code blocks with language labels, line-number gutter, and directive-based integration." /> <MESSAGE value="Add support for code appendices: enable code blocks with language labels, line-number gutter, and directive-based integration." />
<option name="LAST_COMMIT_MESSAGE" value="Add support for code appendices: enable code blocks with language labels, line-number gutter, and directive-based integration." /> <MESSAGE value="Refactor table rendering: replace plain text with `InlineSpan` for rich text support, update row preparation, and improve PDF formatting logic." />
<option name="LAST_COMMIT_MESSAGE" value="Refactor table rendering: replace plain text with `InlineSpan` for rich text support, update row preparation, and improve PDF formatting logic." />
</component> </component>
<component name="XDebuggerManager"> <component name="XDebuggerManager">
<breakpoint-manager> <breakpoint-manager>
Binary file not shown.
+5
View File
@@ -1,9 +1,14 @@
github.com/boombuler/barcode v1.0.1/go.mod h1:paBWMcWSl3LHKBqUq+rly7CNSldXjb2rDl3JlRe0mD8=
github.com/go-pdf/fpdf v0.9.0 h1:PPvSaUuo1iMi9KkaAn90NuKi+P4gwMedWPHhj8YlJQw= github.com/go-pdf/fpdf v0.9.0 h1:PPvSaUuo1iMi9KkaAn90NuKi+P4gwMedWPHhj8YlJQw=
github.com/go-pdf/fpdf v0.9.0/go.mod h1:oO8N111TkmKb9D7VvWGLvLJlaZUQVPM+6V42pp3iV4Y= github.com/go-pdf/fpdf v0.9.0/go.mod h1:oO8N111TkmKb9D7VvWGLvLJlaZUQVPM+6V42pp3iV4Y=
github.com/phpdave11/gofpdi v1.0.13/go.mod h1:vBmVV0Do6hSBHC8uKUQ71JGW+ZGQq74llk/7bXwjDoI=
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/ruudk/golang-pdf417 v0.0.0-20201230142125-a7e3863a1245/go.mod h1:pQAZKsJ8yyVxGRWYNEm9oFB8ieLgKFnamEyDmSA0BRk=
github.com/yuin/goldmark v1.8.1 h1:id2TeYXe5FpqwLco0Pso4cNM5Z6Okt4g7kDw9QBMhTA= github.com/yuin/goldmark v1.8.1 h1:id2TeYXe5FpqwLco0Pso4cNM5Z6Okt4g7kDw9QBMhTA=
github.com/yuin/goldmark v1.8.1/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg= github.com/yuin/goldmark v1.8.1/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg=
github.com/yuin/goldmark-meta v1.1.0 h1:pWw+JLHGZe8Rk0EGsMVssiNb/AaPMHfSRszZeUeiOUc= github.com/yuin/goldmark-meta v1.1.0 h1:pWw+JLHGZe8Rk0EGsMVssiNb/AaPMHfSRszZeUeiOUc=
github.com/yuin/goldmark-meta v1.1.0/go.mod h1:U4spWENafuA7Zyg+Lj5RqK/MF+ovMYtBvXi1lBb2VP0= github.com/yuin/goldmark-meta v1.1.0/go.mod h1:U4spWENafuA7Zyg+Lj5RqK/MF+ovMYtBvXi1lBb2VP0=
golang.org/x/image v0.12.0/go.mod h1:Lu90jvHG7GfemOIcldsh9A2hS01ocl6oNO7ype5mEnk=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v2 v2.3.0 h1:clyUAQHOM3G0M3f5vQj7LuJrETvjVot3Z5el9nffUtU= gopkg.in/yaml.v2 v2.3.0 h1:clyUAQHOM3G0M3f5vQj7LuJrETvjVot3Z5el9nffUtU=
gopkg.in/yaml.v2 v2.3.0/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v2 v2.3.0/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
+27 -3
View File
@@ -46,6 +46,7 @@ func ParseMarkdown(mdPath string) (Config, ast.Node, []byte, error) {
type parserState struct { type parserState struct {
nextCodeIsAppendix bool nextCodeIsAppendix bool
nextAppendixLandscape bool // set by @AnhangUMLQuer: — landscape for diagram appendix nextAppendixLandscape bool // set by @AnhangUMLQuer: — landscape for diagram appendix
nextAppendixRotated bool // set by @AnhangUMLGedreht: — portrait page, image rotated 90° CCW
appendixTitle string appendixTitle string
nextCodeBlockAppendix bool // set by @AnhangCode: — next non-diagram code block → appendix nextCodeBlockAppendix bool // set by @AnhangCode: — next non-diagram code block → appendix
codeBlockAppendixTitle string codeBlockAppendixTitle string
@@ -122,6 +123,7 @@ func RenderAST(doc ast.Node, content []byte, r *IHKRenderer) error {
state.nextDiagramCaption = "" state.nextDiagramCaption = ""
state.nextCodeIsAppendix = false state.nextCodeIsAppendix = false
state.nextAppendixLandscape = false state.nextAppendixLandscape = false
state.nextAppendixRotated = false
} }
if err == nil { if err == nil {
switch { switch {
@@ -130,15 +132,24 @@ func RenderAST(doc ast.Node, content []byte, r *IHKRenderer) error {
state.nextDiagramLandscape = false state.nextDiagramLandscape = false
state.nextDiagramCaption = "" state.nextDiagramCaption = ""
case state.nextCodeIsAppendix: case state.nextCodeIsAppendix:
if state.nextAppendixLandscape { switch {
case state.nextAppendixLandscape:
r.AddLandscapeAppendix(state.appendixTitle + " | " + imgPath) r.AddLandscapeAppendix(state.appendixTitle + " | " + imgPath)
state.nextAppendixLandscape = false state.nextAppendixLandscape = false
} else { case state.nextAppendixRotated:
r.AddRotatedUMLAppendix(state.appendixTitle, imgPath)
state.nextAppendixRotated = false
default:
r.AddAppendix(state.appendixTitle + " | " + imgPath) r.AddAppendix(state.appendixTitle + " | " + imgPath)
} }
state.nextCodeIsAppendix = false state.nextCodeIsAppendix = false
default: default:
r.RenderImage(imgPath, "Diagram ("+lang+")") caption := state.nextDiagramCaption
if caption == "" {
caption = "Diagram (" + lang + ")"
}
state.nextDiagramCaption = ""
r.RenderImage(imgPath, caption)
} }
return ast.WalkSkipChildren, nil return ast.WalkSkipChildren, nil
} }
@@ -287,6 +298,15 @@ func handleDirectives(text string, state *parserState, r *IHKRenderer) bool {
state.nextAppendixLandscape = true state.nextAppendixLandscape = true
state.appendixTitle = strings.TrimSpace(strings.TrimPrefix(line, "@AnhangUMLQuer:")) state.appendixTitle = strings.TrimSpace(strings.TrimPrefix(line, "@AnhangUMLQuer:"))
handled = true handled = true
case strings.HasPrefix(line, "@AnhangUMLGedreht:"):
// Portrait page, image rotated 90° CCW — long axis runs top-to-bottom.
state.nextCodeIsAppendix = true
state.nextAppendixRotated = true
state.appendixTitle = strings.TrimSpace(strings.TrimPrefix(line, "@AnhangUMLGedreht:"))
handled = true
case strings.HasPrefix(line, "@AnhangBildGedreht:"):
r.AddRotatedImageAppendix(strings.TrimSpace(strings.TrimPrefix(line, "@AnhangBildGedreht:")))
handled = true
case strings.HasPrefix(line, "@AnhangUML:"): case strings.HasPrefix(line, "@AnhangUML:"):
state.nextCodeIsAppendix = true state.nextCodeIsAppendix = true
state.appendixTitle = strings.TrimSpace(strings.TrimPrefix(line, "@AnhangUML:")) state.appendixTitle = strings.TrimSpace(strings.TrimPrefix(line, "@AnhangUML:"))
@@ -307,6 +327,10 @@ func handleDirectives(text string, state *parserState, r *IHKRenderer) bool {
state.nextDiagramLandscape = true state.nextDiagramLandscape = true
state.nextDiagramCaption = strings.TrimSpace(strings.TrimPrefix(line, "@DiagrammQuer:")) state.nextDiagramCaption = strings.TrimSpace(strings.TrimPrefix(line, "@DiagrammQuer:"))
handled = true handled = true
case strings.HasPrefix(line, "@Diagramm:"):
// Portrait inline diagram — rendered at current position via RenderImage.
state.nextDiagramCaption = strings.TrimSpace(strings.TrimPrefix(line, "@Diagramm:"))
handled = true
} }
} }
return handled return handled
+149 -13
View File
@@ -3,6 +3,7 @@ package main
import ( import (
"fmt" "fmt"
"log" "log"
"sort"
"strconv" "strconv"
"strings" "strings"
@@ -30,8 +31,19 @@ func (r *IHKRenderer) RenderHeader(level int, title string) {
// Major sections start on a new page per IHK convention. // Major sections start on a new page per IHK convention.
r.pdf.AddPage() r.pdf.AddPage()
} else if !r.isAtPageTop() { } else if !r.isAtPageTop() {
// IHK: two blank lines before a heading that is not at the page top. // Require enough space for: before-spacing + heading + after-spacing + 3 body
r.pdf.Ln(dinSpaceBeforeHeading) // lines. If the remaining page space is too tight, start a new page instead
// of leaving an orphaned heading at the bottom.
const minBodyLines = 3
needed := dinSpaceBeforeHeading + dinLineHtHeading + dinSpaceAfterHeading + minBodyLines*dinLineHtBody
_, pageH := r.pdf.GetPageSize()
_, _, _, bm := r.pdf.GetMargins()
if r.pdf.GetY()+needed > pageH-bm {
r.pdf.AddPage()
} else {
// IHK: two blank lines before a heading that is not at the page top.
r.pdf.Ln(dinSpaceBeforeHeading)
}
} }
r.RecordHeader(level, title) r.RecordHeader(level, title)
@@ -229,9 +241,93 @@ func (r *IHKRenderer) wrapCellSpans(spans []InlineSpan, maxWidth float64, forceB
return lines return lines
} }
// computeColWidths measures the natural content width of each column and
// returns per-column widths that sum to at most uw.
//
// When columns do not fit at natural width, narrow columns are protected:
// processing from smallest to largest, each column receives its natural width
// if a fair share of the remaining space allows it; only the widest columns
// absorb the compression. This prevents narrow ID/label columns from being
// crushed to illegibility by a single very wide content column.
func (r *IHKRenderer) computeColWidths(data [][][]InlineSpan, numCols int, uw float64) []float64 {
const padding = 4.0 // 2 mm left + 2 mm right per cell
natural := make([]float64, numCols)
for rowIdx, row := range data {
isHeader := rowIdx == 0
for j := 0; j < numCols; j++ {
if j >= len(row) {
continue
}
w := padding
for _, span := range row[j] {
if span.Code {
r.pdf.SetFont("Courier", "", dinFontCaption)
} else {
r.pdf.SetFont("Helvetica", fontStyle(span.Bold || isHeader, span.Italic), dinFontCaption)
}
w += r.pdf.GetStringWidth(r.tr(span.Text))
}
if w > natural[j] {
natural[j] = w
}
}
}
total := 0.0
for _, w := range natural {
total += w
}
if total <= uw {
// Scale all columns up proportionally so the table always fills the full
// usable width. Without this, tables with short content leave a blank gap
// on the right and borders don't align with the page layout.
scale := uw / total
for j := range natural {
natural[j] *= scale
}
return natural
}
// total > uw: scale down, but protect narrow columns from being crushed.
// Sort column indices from narrowest to widest natural width.
// Give each narrow column its full natural width while possible;
// the remaining (wider) columns share whatever space is left, proportionally.
order := make([]int, numCols)
for i := range order {
order[i] = i
}
sort.Slice(order, func(a, b int) bool { return natural[order[a]] < natural[order[b]] })
result := make([]float64, numCols)
remaining := uw
for pass, j := range order {
colsLeft := numCols - pass
fairShare := remaining / float64(colsLeft)
if natural[j] <= fairShare {
result[j] = natural[j]
remaining -= natural[j]
} else {
// This and all following (wider) columns share the remaining space
// proportionally to their natural widths.
naturalTail := 0.0
for _, k := range order[pass:] {
naturalTail += natural[k]
}
for _, k := range order[pass:] {
result[k] = natural[k] / naturalTail * remaining
}
break
}
}
return result
}
// prepareRow wraps each cell's inline spans and returns a tableRowData. // prepareRow wraps each cell's inline spans and returns a tableRowData.
// If bold is true, all cell text is forced bold (used for the header row). // If bold is true, all cell text is forced bold (used for the header row).
func (r *IHKRenderer) prepareRow(rawCells [][]InlineSpan, numCols int, colW, lineHt float64, bold bool) tableRowData { func (r *IHKRenderer) prepareRow(rawCells [][]InlineSpan, numCols int, colWidths []float64, lineHt float64, bold bool) tableRowData {
cells := make([][][]InlineSpan, numCols) cells := make([][][]InlineSpan, numCols)
maxLines := 0 maxLines := 0
for j := 0; j < numCols; j++ { for j := 0; j < numCols; j++ {
@@ -239,7 +335,11 @@ func (r *IHKRenderer) prepareRow(rawCells [][]InlineSpan, numCols int, colW, lin
if j < len(rawCells) { if j < len(rawCells) {
spans = rawCells[j] spans = rawCells[j]
} }
lines := r.wrapCellSpans(spans, colW-2, bold) cw := 0.0
if j < len(colWidths) {
cw = colWidths[j]
}
lines := r.wrapCellSpans(spans, cw-2, bold)
cells[j] = lines cells[j] = lines
if len(lines) > maxLines { if len(lines) > maxLines {
maxLines = len(lines) maxLines = len(lines)
@@ -254,7 +354,15 @@ func (r *IHKRenderer) prepareRow(rawCells [][]InlineSpan, numCols int, colW, lin
// drawRow renders a pre-computed row at the current Y position. // drawRow renders a pre-computed row at the current Y position.
// Cell borders are drawn as rectangles so all cells share a uniform height. // Cell borders are drawn as rectangles so all cells share a uniform height.
// Header cells are rendered bold and centred; body cells honour per-span formatting. // Header cells are rendered bold and centred; body cells honour per-span formatting.
func (r *IHKRenderer) drawRow(row tableRowData, numCols int, colW, lineHt float64, isHeader bool) { func (r *IHKRenderer) drawRow(row tableRowData, numCols int, colWidths []float64, lineHt float64, isHeader bool) {
// Disable fpdf's auto page break for the duration of the row so that
// CellFormat calls inside a row can never trigger a mid-row page break.
// (Rect already doesn't trigger page breaks, but CellFormat does — this
// makes the two consistent and prevents row-content from appearing on the
// wrong page relative to the row background.)
r.pdf.SetAutoPageBreak(false, 0)
defer r.pdf.SetAutoPageBreak(true, dinMarginBottom)
startY := r.pdf.GetY() startY := r.pdf.GetY()
lm, _, _, _ := r.pdf.GetMargins() lm, _, _, _ := r.pdf.GetMargins()
@@ -264,14 +372,25 @@ func (r *IHKRenderer) drawRow(row tableRowData, numCols int, colW, lineHt float6
r.pdf.SetFillColor(255, 255, 255) r.pdf.SetFillColor(255, 255, 255)
} }
// Precompute cumulative x positions for each column.
xPos := make([]float64, numCols+1)
xPos[0] = lm
for j, cw := range colWidths {
xPos[j+1] = xPos[j] + cw
}
// Draw all cell backgrounds and borders first (uniform height via Rect). // Draw all cell backgrounds and borders first (uniform height via Rect).
for j := 0; j < numCols; j++ { for j := 0; j < numCols; j++ {
r.pdf.Rect(lm+float64(j)*colW, startY, colW, row.height, "FD") r.pdf.Rect(xPos[j], startY, colWidths[j], row.height, "FD")
} }
// Render text on top, line by line per cell. // Render text on top, line by line per cell.
for j, cellLines := range row.cells { for j, cellLines := range row.cells {
x := lm + float64(j)*colW x := xPos[j]
cw := 0.0
if j < len(colWidths) {
cw = colWidths[j]
}
for k, spanLine := range cellLines { for k, spanLine := range cellLines {
y := startY + float64(k)*lineHt y := startY + float64(k)*lineHt
if isHeader { if isHeader {
@@ -282,7 +401,7 @@ func (r *IHKRenderer) drawRow(row tableRowData, numCols int, colW, lineHt float6
} }
r.pdf.SetFont("Helvetica", "B", dinFontCaption) r.pdf.SetFont("Helvetica", "B", dinFontCaption)
r.pdf.SetXY(x+1, y) r.pdf.SetXY(x+1, y)
r.pdf.CellFormat(colW-2, lineHt, r.tr(plainText), "", 0, "C", false, 0, "") r.pdf.CellFormat(cw-2, lineHt, r.tr(plainText), "", 0, "C", false, 0, "")
} else { } else {
// Body: render each span with its own font, left-aligned. // Body: render each span with its own font, left-aligned.
r.pdf.SetXY(x+1, y) r.pdf.SetXY(x+1, y)
@@ -330,17 +449,17 @@ func (r *IHKRenderer) renderTableBody(data [][][]InlineSpan, label string) {
} }
uw := r.usableWidth() uw := r.usableWidth()
colW := uw / float64(numCols) colWidths := r.computeColWidths(data, numCols, uw)
lineHt := dinLineHtCaption + 2 lineHt := dinLineHtCaption + 2
_, pageH := r.pdf.GetPageSize() _, pageH := r.pdf.GetPageSize()
_, _, _, bm := r.pdf.GetMargins() _, _, _, bm := r.pdf.GetMargins()
// Pre-compute all rows so we know heights before rendering. // Pre-compute all rows so we know heights before rendering.
headerRow := r.prepareRow(header, numCols, colW, lineHt, true) headerRow := r.prepareRow(header, numCols, colWidths, lineHt, true)
bodyRows := make([]tableRowData, len(data)-1) bodyRows := make([]tableRowData, len(data)-1)
for i := 1; i < len(data); i++ { for i := 1; i < len(data); i++ {
bodyRows[i-1] = r.prepareRow(data[i], numCols, colW, lineHt, false) bodyRows[i-1] = r.prepareRow(data[i], numCols, colWidths, lineHt, false)
} }
renderHeader := func(continued bool) { renderHeader := func(continued bool) {
@@ -352,7 +471,24 @@ func (r *IHKRenderer) renderTableBody(data [][][]InlineSpan, label string) {
r.pdf.SetFont("Helvetica", "B", dinFontCaption) r.pdf.SetFont("Helvetica", "B", dinFontCaption)
r.pdf.CellFormat(0, lineHt, r.tr(title), "", 1, "L", false, 0, "") r.pdf.CellFormat(0, lineHt, r.tr(title), "", 1, "L", false, 0, "")
} }
r.drawRow(headerRow, numCols, colW, lineHt, true) r.drawRow(headerRow, numCols, colWidths, lineHt, true)
}
// Ensure label + header + at least the first body row fit on the current page
// before starting to render. Without this check, drawRow can start rendering
// the header near the bottom of the page and the background rect (drawn by
// Rect, which doesn't trigger auto page break) ends up on a different page
// from the cell text (drawn by CellFormat, which used to trigger page breaks).
labelH := 0.0
if label != "" {
labelH = lineHt
}
minH := labelH + headerRow.height
if len(bodyRows) > 0 {
minH += bodyRows[0].height
}
if r.pdf.GetY()+minH > pageH-bm {
r.pdf.AddPage()
} }
renderHeader(false) renderHeader(false)
@@ -362,7 +498,7 @@ func (r *IHKRenderer) renderTableBody(data [][][]InlineSpan, label string) {
r.pdf.AddPage() r.pdf.AddPage()
renderHeader(true) renderHeader(true)
} }
r.drawRow(row, numCols, colW, lineHt, false) r.drawRow(row, numCols, colWidths, lineHt, false)
} }
r.pdf.Ln(dinSpaceAfterParagraph) r.pdf.Ln(dinSpaceAfterParagraph)
} }
+97 -3
View File
@@ -2,7 +2,12 @@ package main
import ( import (
"fmt" "fmt"
"image"
"image/draw"
_ "image/jpeg"
"image/png"
"log" "log"
"os"
"sort" "sort"
"github.com/go-pdf/fpdf" "github.com/go-pdf/fpdf"
@@ -142,7 +147,7 @@ func (r *IHKRenderer) RenderAppendices() {
r.pdf.SetFont("Helvetica", "", dinFontBody) r.pdf.SetFont("Helvetica", "", dinFontBody)
for i, app := range r.appendices { for i, app := range r.appendices {
r.pdf.CellFormat(0, dinLineHtBody, r.pdf.CellFormat(0, dinLineHtBody,
r.tr(fmt.Sprintf("Anlage %d: %s", i+1, app.Title)), "", 1, "L", false, 0, "") r.tr(fmt.Sprintf("Anlage %s: %s", toRoman(i+1), app.Title)), "", 1, "L", false, 0, "")
} }
// Save portrait dimensions and standard margins for post-landscape restoration. // Save portrait dimensions and standard margins for post-landscape restoration.
@@ -164,12 +169,16 @@ func (r *IHKRenderer) RenderAppendices() {
r.pdf.SetFont("Helvetica", "B", dinFontBody) r.pdf.SetFont("Helvetica", "B", dinFontBody)
r.pdf.CellFormat(0, dinLineHtBody, r.pdf.CellFormat(0, dinLineHtBody,
r.tr(fmt.Sprintf("Anlage %d: %s", i+1, app.Title)), "", 1, "L", false, 0, "") r.tr(fmt.Sprintf("Anlage %s: %s", toRoman(i+1), app.Title)), "", 1, "L", false, 0, "")
r.pdf.Ln(dinSpaceAfterHeading) r.pdf.Ln(dinSpaceAfterHeading)
switch app.Kind { switch app.Kind {
case AppendixKindImage: case AppendixKindImage:
r.renderAppendixImage(app.Path) if app.Rotated {
r.renderAppendixImageRotated(app.Path)
} else {
r.renderAppendixImage(app.Path)
}
case AppendixKindTable: case AppendixKindTable:
r.renderTableBody(app.TableData, "") r.renderTableBody(app.TableData, "")
case AppendixKindCode: case AppendixKindCode:
@@ -215,6 +224,91 @@ func (r *IHKRenderer) renderAppendixImage(path string) {
fpdf.ImageOptions{ReadDpi: true}, 0, "") 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. // RenderAbbreviations renders the list of abbreviations from the YAML config.
// It is placed after the TOC and uses Roman page numbering. // It is placed after the TOC and uses Roman page numbering.
func (r *IHKRenderer) RenderAbbreviations() { func (r *IHKRenderer) RenderAbbreviations() {
+30
View File
@@ -55,6 +55,7 @@ const (
type Appendix struct { type Appendix struct {
Kind AppendixKind Kind AppendixKind
Landscape bool // true → render on a landscape A4 page (15 mm symmetric margins) Landscape bool // true → render on a landscape A4 page (15 mm symmetric margins)
Rotated bool // true → portrait page, image rotated 90° CCW so long axis runs top-to-bottom
Title string Title string
Path string // image path (Kind == AppendixKindImage) Path string // image path (Kind == AppendixKindImage)
TableData [][][]InlineSpan // table rows (Kind == AppendixKindTable) TableData [][][]InlineSpan // table rows (Kind == AppendixKindTable)
@@ -194,6 +195,35 @@ func (r *IHKRenderer) AddCodeAppendix(title, lang, code string) {
}) })
} }
// AddRotatedImageAppendix registers an image annex rendered on a portrait A4 page
// with the image rotated 90° CCW, so a wide (landscape-ratio) diagram fills the
// full page height when the reader tilts the page 90° clockwise.
// Format: "Title | /path/to/image"
func (r *IHKRenderer) AddRotatedImageAppendix(titlePath string) {
parts := strings.SplitN(titlePath, "|", 2)
if len(parts) < 2 {
log.Printf("warning: @AnhangBildGedreht directive missing '|' separator: %q", titlePath)
return
}
r.appendices = append(r.appendices, Appendix{
Kind: AppendixKindImage,
Rotated: true,
Title: strings.TrimSpace(parts[0]),
Path: strings.TrimSpace(parts[1]),
})
}
// AddRotatedUMLAppendix registers a UML/diagram annex (code block) rendered on a
// portrait A4 page with the diagram image rotated 90° CCW.
func (r *IHKRenderer) AddRotatedUMLAppendix(title, imgPath string) {
r.appendices = append(r.appendices, Appendix{
Kind: AppendixKindImage,
Rotated: true,
Title: title,
Path: imgPath,
})
}
// AddLandscapeAppendix registers an image annex in "Title | /path/to/image" format // AddLandscapeAppendix registers an image annex in "Title | /path/to/image" format
// that will be rendered on a landscape A4 page with 15 mm symmetric margins. // that will be rendered on a landscape A4 page with 15 mm symmetric margins.
func (r *IHKRenderer) AddLandscapeAppendix(titlePath string) { func (r *IHKRenderer) AddLandscapeAppendix(titlePath string) {
BIN
View File
Binary file not shown.