package reports import "io" // Schriftgrade des Berichts. const ( // titleFontSize ist der Grad der Berichtsueberschrift. titleFontSize = 18.0 // sectionFontSize ist der Grad einer Abschnittsueberschrift. sectionFontSize = 13.0 // bodyFontSize ist der Grad des Fliesstexts. bodyFontSize = 9.5 // tableFontSize ist der Grad in Tabellen. // // Kleiner als der Fliesstext: Eine Tabelle mit sechs Spalten passt sonst // nicht auf A4, und eine querformatige Seite mitten im Bericht ist // unangenehmer als eine halbe Stufe kleinere Schrift. tableFontSize = 8.0 ) // WritePDF schreibt einen Bericht als PDF. func WritePDF(outputWriter io.Writer, report *Report) error { document := newPDFDocument() writePDFHeader(document, report) for _, section := range report.Sections { writePDFSection(document, section) } writePDFNotes(document, report) _, writeError := outputWriter.Write(document.bytes()) return writeError } // writePDFHeader schreibt Titel und Kopfangaben. func writePDFHeader(document *pdfDocument, report *Report) { document.drawText(report.Title, fontBold, titleFontSize, 0) document.addSpace(2) for _, descriptionLine := range wrapText(report.Description, fontRegular, bodyFontSize, contentWidth) { document.drawText(descriptionLine, fontRegular, bodyFontSize, 0) } document.addSpace(4) // Ein Zustandsbericht bekommt keinen Zeitraum angezeigt. Ihn mit zwei // gleichen Zeitpunkten auszuweisen sähe nach einem Fehler aus. if report.PeriodFrom.Before(report.PeriodTo) { document.drawText("Zeitraum: "+FormatTimestamp(report.PeriodFrom)+" bis "+ FormatTimestamp(report.PeriodTo), fontRegular, bodyFontSize, 0) } else { document.drawText("Zustand zum "+FormatTimestamp(report.PeriodTo), fontRegular, bodyFontSize, 0) } generationLine := "Erzeugt am " + FormatTimestamp(report.GeneratedAt) if report.GeneratedBy != "" { generationLine += " von " + report.GeneratedBy } document.drawText(generationLine, fontRegular, bodyFontSize, 0) document.addSpace(4) document.drawRule(1, 0.3) } // writePDFSection schreibt einen Abschnitt. func writePDFSection(document *pdfDocument, section Section) { document.addSpace(8) document.drawText(section.Title, fontBold, sectionFontSize, 0) if section.Description != "" { for _, descriptionLine := range wrapText(section.Description, fontRegular, bodyFontSize, contentWidth) { document.drawText(descriptionLine, fontRegular, bodyFontSize, 0) } } document.addSpace(4) writePDFMetrics(document, section.Metrics) for _, table := range section.Tables { writePDFTable(document, table) } } // writePDFMetrics schreibt die Kennzahlen eines Abschnitts. // // Die Beschriftung steht links, der Wert an einer festen Spalte rechts davon. // Eine ungemessene Kennzahl zeigt „nicht gemessen" und darunter den Grund — // nicht eine Null, die im Bericht wie eine Messung aussaehe. func writePDFMetrics(document *pdfDocument, metrics []Metric) { const valueColumnOffset = 260.0 for _, metric := range metrics { document.ensureSpace(bodyFontSize + 4) labelY := document.cursorY document.drawText(truncateToWidth(metric.Label, fontRegular, bodyFontSize, valueColumnOffset-8), fontRegular, bodyFontSize, 0) // Der Wert gehoert auf dieselbe Zeile; drawText hat den Zeiger schon // weitergesetzt, also wird er kurz zurueckgestellt. valueY := document.cursorY document.cursorY = labelY document.drawText(metric.Display(), fontBold, bodyFontSize, valueColumnOffset) document.cursorY = valueY if !metric.IsKnown && metric.UnknownReason != "" { for _, reasonLine := range wrapText(metric.UnknownReason, fontRegular, bodyFontSize-1, contentWidth-valueColumnOffset) { document.drawText(reasonLine, fontRegular, bodyFontSize-1, valueColumnOffset) } } } } // writePDFTable schreibt eine Tabelle. func writePDFTable(document *pdfDocument, table Table) { document.addSpace(6) document.drawText(table.Title, fontBold, bodyFontSize+0.5, 0) document.addSpace(2) if len(table.Rows) == 0 { document.drawText(emptyNoticeOf(table), fontRegular, bodyFontSize, 8) return } columnOffsets, columnWidths := measureColumns(table) document.ensureSpace(3 * (tableFontSize + 3)) headerY := document.cursorY for columnIndex, columnLabel := range table.Columns { document.cursorY = headerY document.drawText(truncateToWidth(columnLabel, fontBold, tableFontSize, columnWidths[columnIndex]), fontBold, tableFontSize, columnOffsets[columnIndex]) } document.cursorY = headerY - tableFontSize - 3 document.drawRule(0.5, 0.6) for _, tableRow := range table.Rows { document.ensureSpace(tableFontSize + 4) rowY := document.cursorY for columnIndex := range table.Columns { cellText := "" if columnIndex < len(tableRow) { cellText = tableRow[columnIndex] } document.cursorY = rowY document.drawText(truncateToWidth(cellText, fontRegular, tableFontSize, columnWidths[columnIndex]), fontRegular, tableFontSize, columnOffsets[columnIndex]) } document.cursorY = rowY - tableFontSize - 3 } } // measureColumns bestimmt Position und Breite jeder Spalte. // // Gemessen wird der breiteste tatsaechliche Inhalt, nicht die Spaltenzahl: // Gleich breite Spalten verschenken den Platz an eine Datumsspalte und nehmen // ihn dem Pfad, der die eigentliche Auskunft traegt. // // Passt die Summe nicht auf die Seite, geben **nur die breitesten Spalten** ab. // Ein gleichmaessiger Stauchfaktor waere der naheliegende Weg und der falsche: // Er kuerzte in der Probe „TEILWEISE FEHLGESCHLAGEN" zu „TEILWEISE FEHL…", // weil daneben ein langer Pfad stand — ausgerechnet der Wert, wegen dessen man // den Bericht liest. Gesucht wird deshalb eine gemeinsame Obergrenze: Spalten // darunter bleiben unangetastet, alles darueber wird auf sie gekappt. func measureColumns(table Table) ([]float64, []float64) { const columnGap = 8.0 columnCount := len(table.Columns) naturalWidths := make([]float64, columnCount) for columnIndex, columnLabel := range table.Columns { naturalWidths[columnIndex] = textWidth(columnLabel, fontBold, tableFontSize) } for _, tableRow := range table.Rows { for columnIndex := 0; columnIndex < columnCount && columnIndex < len(tableRow); columnIndex++ { cellWidth := textWidth(tableRow[columnIndex], fontRegular, tableFontSize) if cellWidth > naturalWidths[columnIndex] { naturalWidths[columnIndex] = cellWidth } } } totalWidth := 0.0 for _, naturalWidth := range naturalWidths { totalWidth += naturalWidth } availableWidth := contentWidth - float64(columnCount-1)*columnGap widthCap := findWidthCap(naturalWidths, availableWidth, totalWidth) columnOffsets := make([]float64, columnCount) columnWidths := make([]float64, columnCount) currentOffset := 0.0 for columnIndex := range columnCount { columnWidths[columnIndex] = min(naturalWidths[columnIndex], widthCap) columnOffsets[columnIndex] = currentOffset currentOffset += columnWidths[columnIndex] + columnGap } return columnOffsets, columnWidths } // findWidthCap sucht die Obergrenze, bei der alle Spalten auf die Seite passen. // // Die Spalten werden aufsteigend durchlaufen: Bleibt fuer die verbleibenden im // Schnitt mehr Platz uebrig, als die aktuelle braucht, bekommt sie ihre volle // Breite und gibt den Rest weiter. Andernfalls teilen sich alle uebrigen den // Rest zu gleichen Teilen — das ist die gesuchte Grenze. func findWidthCap(naturalWidths []float64, availableWidth float64, totalWidth float64) float64 { if totalWidth <= availableWidth { return availableWidth } sortedWidths := make([]float64, len(naturalWidths)) copy(sortedWidths, naturalWidths) for outerIndex := 1; outerIndex < len(sortedWidths); outerIndex++ { currentWidth := sortedWidths[outerIndex] innerIndex := outerIndex - 1 for innerIndex >= 0 && sortedWidths[innerIndex] > currentWidth { sortedWidths[innerIndex+1] = sortedWidths[innerIndex] innerIndex-- } sortedWidths[innerIndex+1] = currentWidth } remainingWidth := availableWidth for widthIndex, sortedWidth := range sortedWidths { remainingColumns := float64(len(sortedWidths) - widthIndex) equalShare := remainingWidth / remainingColumns if sortedWidth > equalShare { return equalShare } remainingWidth -= sortedWidth } return availableWidth } // writePDFNotes schreibt die Hinweise ans Ende. func writePDFNotes(document *pdfDocument, report *Report) { if len(report.Notes) == 0 { return } document.addSpace(10) document.drawRule(0.5, 0.6) document.drawText("Hinweise", fontBold, bodyFontSize+1, 0) document.addSpace(2) for _, note := range report.Notes { noteLines := wrapText(note, fontRegular, bodyFontSize-0.5, contentWidth-12) for lineIndex, noteLine := range noteLines { linePrefix := " " if lineIndex == 0 { linePrefix = "– " } document.drawText(linePrefix+noteLine, fontRegular, bodyFontSize-0.5, 0) } } }