package reports import ( "bytes" "fmt" "strings" ) // Der PDF-Schreiber ist bewusst selbst geschrieben. // // Der Plan sagt „PDF where practical". Die bequeme Auslegung waere, es fuer // nicht praktikabel zu erklaeren — aber ein Bericht fuer eine Pruefung wird als // PDF verlangt, nicht als CSV. Und was hier gebraucht wird, ist der einfachste // Teil des Formats: Text in einer der vierzehn Standardschriften, die jeder // Betrachter mitbringt. Keine Einbettung, keine Bilder, keine Transparenz. // // Der Preis ist die Breitentabelle weiter unten. Ohne sie liesse sich weder // umbrechen noch eine Spalte bemessen; mit geschaetzten Breiten ueberlappen // Tabellenspalten, sobald ein Wert laenger wird als erwartet. // Seitenmasse in Punkten (72 dpi). A4 ist 595 × 842. const ( // pageWidth ist die Seitenbreite in Punkten. pageWidth = 595.0 // pageHeight ist die Seitenhoehe in Punkten. pageHeight = 842.0 // pageMargin ist der Seitenrand in Punkten. pageMargin = 48.0 // contentWidth ist die nutzbare Breite. contentWidth = pageWidth - 2*pageMargin // bottomLimit ist die Hoehe, ab der eine neue Seite beginnt. bottomLimit = pageMargin + 24 ) // pdfFont benennt eine der beiden verwendeten Schriften. type pdfFont string const ( // fontRegular ist die Grundschrift. fontRegular pdfFont = "F1" // fontBold ist die Auszeichnungsschrift. fontBold pdfFont = "F2" ) // pdfDocument sammelt die Seiten eines Dokuments. type pdfDocument struct { // pageStreams sind die fertigen Inhaltsstroeme je Seite. pageStreams []*bytes.Buffer // currentStream ist der Strom der Seite, auf die gerade geschrieben wird. currentStream *bytes.Buffer // cursorY ist die aktuelle Schreibhoehe, von unten gemessen. cursorY float64 } // newPDFDocument beginnt ein Dokument mit einer ersten Seite. func newPDFDocument() *pdfDocument { document := &pdfDocument{} document.beginPage() return document } // beginPage beginnt eine neue Seite. func (document *pdfDocument) beginPage() { document.currentStream = &bytes.Buffer{} document.pageStreams = append(document.pageStreams, document.currentStream) document.cursorY = pageHeight - pageMargin } // ensureSpace beginnt eine neue Seite, wenn der Platz nicht reicht. func (document *pdfDocument) ensureSpace(neededHeight float64) { if document.cursorY-neededHeight < bottomLimit { document.beginPage() } } // drawText schreibt eine Zeile an der aktuellen Position. func (document *pdfDocument) drawText(text string, font pdfFont, fontSize float64, offsetX float64) { document.ensureSpace(fontSize + 2) fmt.Fprintf(document.currentStream, "BT /%s %.1f Tf %.1f %.1f Td (%s) Tj ET\n", font, fontSize, pageMargin+offsetX, document.cursorY, escapePDFString(text)) document.cursorY -= fontSize + 3 } // drawRule zieht eine waagerechte Linie. func (document *pdfDocument) drawRule(lineWidth float64, greyLevel float64) { document.ensureSpace(6) fmt.Fprintf(document.currentStream, "%.2f G %.2f w %.1f %.1f m %.1f %.1f l S\n", greyLevel, lineWidth, pageMargin, document.cursorY, pageMargin+contentWidth, document.cursorY) document.cursorY -= 8 } // addSpace laesst senkrechten Raum. func (document *pdfDocument) addSpace(height float64) { document.cursorY -= height } // escapePDFString maskiert die drei Zeichen mit Sonderbedeutung in PDF. func escapePDFString(text string) string { encodedText := encodeWinAnsi(text) replacer := strings.NewReplacer(`\`, `\\`, `(`, `\(`, `)`, `\)`) return replacer.Replace(encodedText) } // encodeWinAnsi wandelt UTF-8 in die Kodierung der Standardschriften. // // Die vierzehn Standardschriften werden hier mit /WinAnsiEncoding eingebunden; // jedes Zeichen ist also **ein** Byte. Ein deutscher Bericht ohne diese // Umwandlung zeigt „Sicherungsläufe" — und zwar nicht in der Vorschau des // Entwicklers, sondern erst im Betrachter des Empfaengers. func encodeWinAnsi(text string) string { encodedBytes := make([]byte, 0, len(text)) for _, textRune := range text { switch { case textRune < 128: encodedBytes = append(encodedBytes, byte(textRune)) case textRune <= 255: // Latin-1 deckt die deutschen Umlaute und das scharfe S ab. encodedBytes = append(encodedBytes, byte(textRune)) default: if winAnsiByte, isKnown := winAnsiSpecials[textRune]; isKnown { encodedBytes = append(encodedBytes, winAnsiByte) continue } // Ein nicht darstellbares Zeichen wird zu einem Fragezeichen. Es // stillschweigend wegzulassen erzeugte einen Text, der etwas // anderes bedeutet als der urspruengliche. encodedBytes = append(encodedBytes, '?') } } return string(encodedBytes) } // winAnsiSpecials sind die Zeichen, die WinAnsi ausserhalb von Latin-1 kennt. // // Nur die, die in diesen Berichten wirklich vorkommen: typografische // Anfuehrungszeichen, Gedankenstriche, Auslassungspunkte und das Eurozeichen. var winAnsiSpecials = map[rune]byte{ '€': 0x80, '‚': 0x82, '„': 0x84, '…': 0x85, '†': 0x86, '‡': 0x87, '‰': 0x89, '‹': 0x8B, '‘': 0x91, '’': 0x92, '“': 0x93, '”': 0x94, '•': 0x95, '–': 0x96, '—': 0x97, '›': 0x9B, } // bytes serialisiert das Dokument. // // Aufbau: Katalog, Seitenbaum, zwei Schriften, dann je Seite ein Seitenobjekt // und ein Inhaltsstrom. Die Querverweistabelle am Ende braucht die Byte-Offsets // aller Objekte — deshalb wird beim Schreiben mitgezaehlt. func (document *pdfDocument) bytes() []byte { pageCount := len(document.pageStreams) // 1 Katalog, 2 Seitenbaum, 3 und 4 Schriften, danach je Seite zwei Objekte. const firstPageObject = 5 outputBuffer := &bytes.Buffer{} objectOffsets := make([]int, 0, 4+2*pageCount) writeObject := func(objectBody string) { objectOffsets = append(objectOffsets, outputBuffer.Len()) outputBuffer.WriteString(objectBody) } outputBuffer.WriteString("%PDF-1.4\n") // Ein Kommentar mit Bytes ueber 127 kennzeichnet die Datei als binaer. // Ohne ihn behandeln manche Uebertragungswege sie als Text und ersetzen // Zeilenenden — die Querverweistabelle stimmt danach nicht mehr. outputBuffer.WriteString("%\xE2\xE3\xCF\xD3\n") pageReferences := make([]string, 0, pageCount) for pageIndex := range pageCount { pageReferences = append(pageReferences, fmt.Sprintf("%d 0 R", firstPageObject+2*pageIndex)) } writeObject("1 0 obj\n<< /Type /Catalog /Pages 2 0 R >>\nendobj\n") writeObject(fmt.Sprintf("2 0 obj\n<< /Type /Pages /Count %d /Kids [%s] >>\nendobj\n", pageCount, strings.Join(pageReferences, " "))) writeObject("3 0 obj\n<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica " + "/Encoding /WinAnsiEncoding >>\nendobj\n") writeObject("4 0 obj\n<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica-Bold " + "/Encoding /WinAnsiEncoding >>\nendobj\n") for pageIndex, pageStream := range document.pageStreams { pageObjectNumber := firstPageObject + 2*pageIndex streamObjectNumber := pageObjectNumber + 1 writeObject(fmt.Sprintf("%d 0 obj\n<< /Type /Page /Parent 2 0 R "+ "/MediaBox [0 0 %.0f %.0f] /Resources << /Font << /F1 3 0 R /F2 4 0 R >> >> "+ "/Contents %d 0 R >>\nendobj\n", pageObjectNumber, pageWidth, pageHeight, streamObjectNumber)) writeObject(fmt.Sprintf("%d 0 obj\n<< /Length %d >>\nstream\n%s\nendstream\nendobj\n", streamObjectNumber, pageStream.Len()+1, pageStream.String())) } crossReferenceOffset := outputBuffer.Len() objectCount := len(objectOffsets) + 1 fmt.Fprintf(outputBuffer, "xref\n0 %d\n0000000000 65535 f \n", objectCount) for _, objectOffset := range objectOffsets { fmt.Fprintf(outputBuffer, "%010d 00000 n \n", objectOffset) } fmt.Fprintf(outputBuffer, "trailer\n<< /Size %d /Root 1 0 R >>\nstartxref\n%d\n%%%%EOF\n", objectCount, crossReferenceOffset) return outputBuffer.Bytes() }