| 1 | package main |
| 2 | |
| 3 | import ( |
| 4 | "context" |
| 5 | "encoding/json" |
| 6 | "errors" |
| 7 | "fmt" |
| 8 | "io" |
| 9 | "os" |
| 10 | "path/filepath" |
| 11 | "strings" |
| 12 | "unicode/utf16" |
| 13 | ) |
| 14 | |
| 15 | type pageDimensions struct { |
| 16 | Width int `json:"width"` |
| 17 | Height int `json:"height"` |
| 18 | } |
| 19 | type exportPDFWriter struct { |
| 20 | dst io.Writer |
| 21 | offset int64 |
| 22 | err error |
| 23 | } |
| 24 | |
| 25 | func (w *exportPDFWriter) write(value string) { |
| 26 | if w.err != nil { |
| 27 | return |
| 28 | } |
| 29 | var n int |
| 30 | n, w.err = io.WriteString(w.dst, value) |
| 31 | w.offset += int64(n) |
| 32 | } |
| 33 | func (w *exportPDFWriter) object(id int, body string, offsets []int64) { |
| 34 | offsets[id] = w.offset |
| 35 | w.write(fmt.Sprintf("%d 0 obj\n%s\nendobj\n", id, body)) |
| 36 | } |
| 37 | func writeExportPDF(ctx context.Context, dst io.Writer, dir string, count int, title string) error { |
| 38 | if count == 0 { |
| 39 | return errors.New("no export pages") |
| 40 | } |
| 41 | w := &exportPDFWriter{dst: dst} |
| 42 | offsets := make([]int64, 4+count*3) |
| 43 | w.write("%PDF-1.4\n%\xff\xff\xff\xff\n") |
| 44 | w.object(1, "<< /Type /Catalog /Pages 2 0 R >>", offsets) |
| 45 | var kids strings.Builder |
| 46 | for i := range count { |
| 47 | if err := ctx.Err(); err != nil { |
| 48 | return err |
| 49 | } |
| 50 | pageID := 3 + i*3 |
| 51 | fmt.Fprintf(&kids, "%d 0 R ", pageID) |
| 52 | path := filepath.Join(dir, fmt.Sprintf("page-%06d", i)) |
| 53 | meta, err := os.ReadFile(path + ".json") |
| 54 | if err != nil { |
| 55 | return err |
| 56 | } |
| 57 | var dimensions pageDimensions |
| 58 | if err = json.Unmarshal(meta, &dimensions); err != nil { |
| 59 | return err |
| 60 | } |
| 61 | file, err := os.Open(path) |
| 62 | if err != nil { |
| 63 | return err |
| 64 | } |
| 65 | info, err := file.Stat() |
| 66 | if err != nil { |
| 67 | file.Close() |
| 68 | return err |
| 69 | } |
| 70 | height := min(769.89, float64(dimensions.Height)*523.28/float64(dimensions.Width)) |
| 71 | y := 841.89 - 36 - height |
| 72 | w.object(pageID, fmt.Sprintf("<< /Type /Page /Parent 2 0 R /MediaBox [0 0 595.28 841.89] /Resources << /XObject << /Im0 %d 0 R >> >> /Contents %d 0 R >>", pageID+2, pageID+1), offsets) |
| 73 | commands := fmt.Sprintf("q\n523.28 0 0 %.3f 36 %.3f cm\n/Im0 Do\nQ\n", height, y) |
| 74 | w.object(pageID+1, fmt.Sprintf("<< /Length %d >>\nstream\n%sendstream", len(commands), commands), offsets) |
| 75 | offsets[pageID+2] = w.offset |
| 76 | w.write(fmt.Sprintf("%d 0 obj\n<< /Type /XObject /Subtype /Image /Width %d /Height %d /ColorSpace /DeviceRGB /BitsPerComponent 8 /Filter /DCTDecode /Length %d >>\nstream\n", pageID+2, dimensions.Width, dimensions.Height, info.Size())) |
| 77 | if w.err != nil { |
| 78 | file.Close() |
| 79 | return w.err |
| 80 | } |
| 81 | n, err := copyExportContext(ctx, dst, file) |
| 82 | file.Close() |
| 83 | if err != nil { |
| 84 | return err |
| 85 | } |
| 86 | w.offset += n |
| 87 | w.write("\nendstream\nendobj\n") |
| 88 | } |
| 89 | w.object(2, fmt.Sprintf("<< /Type /Pages /Kids [%s] /Count %d >>", kids.String(), count), offsets) |
| 90 | // UTF-16BE PDF strings preserve non-ASCII titles. |
| 91 | encoded := utf16.Encode([]rune(title)) |
| 92 | var hexTitle strings.Builder |
| 93 | hexTitle.WriteString("FEFF") |
| 94 | for _, unit := range encoded { |
| 95 | fmt.Fprintf(&hexTitle, "%04X", unit) |
| 96 | } |
| 97 | w.object(3+count*3, "<< /Title <"+hexTitle.String()+"> /Producer (Reasonix) >>", offsets) |
| 98 | xref := w.offset |
| 99 | w.write(fmt.Sprintf("xref\n0 %d\n0000000000 65535 f \n", len(offsets))) |
| 100 | for _, offset := range offsets[1:] { |
| 101 | w.write(fmt.Sprintf("%010d 00000 n \n", offset)) |
| 102 | } |
| 103 | w.write(fmt.Sprintf("trailer\n<< /Size %d /Root 1 0 R /Info %d 0 R >>\nstartxref\n%d\n%%%%EOF\n", len(offsets), 3+count*3, xref)) |
| 104 | return w.err |
| 105 | } |
| 106 | |
| 107 | func publishExportImages(ctx context.Context, dir, path string, count int) ([]string, error) { |
| 108 | if count == 0 { |
| 109 | return nil, errors.New("no export pages") |
| 110 | } |
| 111 | targets := make([]string, count) |
| 112 | for i := range targets { |
| 113 | targets[i] = numberedExportPath(path, i, count) |
| 114 | } |
| 115 | if count == 1 { |
| 116 | err := writeStreamingExport(path, func(dst io.Writer) error { |
| 117 | src, err := os.Open(filepath.Join(dir, "page-000000")) |
| 118 | if err != nil { |
| 119 | return err |
| 120 | } |
| 121 | defer src.Close() |
| 122 | _, err = copyExportContext(ctx, dst, src) |
| 123 | return err |
| 124 | }) |
| 125 | return targets, err |
| 126 | } |
| 127 | err := publishJournaledImages(ctx, dir, targets) |
| 128 | return targets, err |
| 129 | } |
| 130 | |
| 131 | func numberedExportPath(path string, partIndex, partCount int) string { |
| 132 | if partCount <= 1 { |
| 133 | return path |
| 134 | } |
| 135 | ext := filepath.Ext(path) |
| 136 | stem := strings.TrimSuffix(path, ext) |
| 137 | return fmt.Sprintf("%s-%d-of-%d%s", stem, partIndex+1, partCount, ext) |
| 138 | } |
| 139 |