oficina / docs Public

Clone
git clone https://amadan.net/oficina/docs

Plain git — no account needed to clone.

Download

Download this file

1package export_test
2
3// A small PDF reader, for tests only.
4//
5// The point of it is that a PDF export cannot be checked by comparing
6// bytes — the interesting properties are "a reader can find the
7// objects", "the pages are there", and "the text can be selected and
8// copied back out", and none of those is visible in a byte comparison.
9// This walks the file the way a reader does: through the trailer to
10// the cross-reference table, through the offsets in it to the objects,
11// and through each font's ToUnicode CMap to turn the glyph ids in a
12// content stream back into words.
13//
14// It follows the file's own xref offsets rather than scanning for
15// "obj", which means a wrong offset fails the test rather than being
16// routed around — the offsets are exactly the kind of thing that is
17// silently wrong in a hand-written PDF writer.
18
19import (
20 "bytes"
21 "compress/zlib"
22 "fmt"
23 "io"
24 "regexp"
25 "strconv"
26 "strings"
27 "testing"
28)
29
30type pdfObject struct {
31 dict string
32 stream []byte // already decompressed
33}
34
35type pdfFile struct {
36 objects map[int]pdfObject
37}
38
39func readPDF(t *testing.T, data []byte) pdfFile {
40 t.Helper()
41
42 if !bytes.HasPrefix(data, []byte("%PDF-1.")) {
43 t.Fatalf("not a PDF: starts with %q", firstLine(data))
44 }
45 if !bytes.HasSuffix(bytes.TrimRight(data, "\n"), []byte("%%EOF")) {
46 t.Fatal("file does not end with the end-of-file marker")
47 }
48
49 // startxref, then the offset on the following line.
50 idx := bytes.LastIndex(data, []byte("startxref"))
51 if idx < 0 {
52 t.Fatal("no startxref")
53 }
54 var xrefAt int
55 if _, err := fmt.Sscanf(string(data[idx:]), "startxref\n%d", &xrefAt); err != nil {
56 t.Fatalf("unreadable startxref: %v", err)
57 }
58 if xrefAt <= 0 || xrefAt >= len(data) {
59 t.Fatalf("startxref points outside the file: %d of %d", xrefAt, len(data))
60 }
61
62 var first, count int
63 if _, err := fmt.Sscanf(string(data[xrefAt:]), "xref\n%d %d", &first, &count); err != nil {
64 t.Fatalf("unreadable xref header at %d: %v", xrefAt, err)
65 }
66 if first != 0 {
67 t.Fatalf("xref starts at object %d, want 0", first)
68 }
69
70 // Entries are exactly 20 bytes each, starting after the two header
71 // lines. Finding the entry table by scanning past the header keeps
72 // this honest about the header's own length.
73 entriesAt := xrefAt + strings.Index(string(data[xrefAt:]), "\n") + 1
74 entriesAt += strings.Index(string(data[entriesAt:]), "\n") + 1
75
76 out := pdfFile{objects: map[int]pdfObject{}}
77 for i := 1; i < count; i++ {
78 at := entriesAt + i*20
79 if at+20 > len(data) {
80 t.Fatalf("xref entry %d runs past the end of the file", i)
81 }
82 entry := string(data[at : at+20])
83 off, err := strconv.Atoi(strings.TrimSpace(entry[:10]))
84 if err != nil {
85 t.Fatalf("xref entry %d is not a number: %q", i, entry)
86 }
87 out.objects[i] = readObject(t, data, i, off)
88 }
89 return out
90}
91
92var lengthRe = regexp.MustCompile(`/Length (\d+)`)
93
94func readObject(t *testing.T, data []byte, num, off int) pdfObject {
95 t.Helper()
96 if off <= 0 || off >= len(data) {
97 t.Fatalf("object %d: offset %d is outside the file", num, off)
98 }
99 want := fmt.Sprintf("%d 0 obj\n", num)
100 if !bytes.HasPrefix(data[off:], []byte(want)) {
101 t.Fatalf("object %d: xref points at %q, not %q", num, firstLine(data[off:]), strings.TrimSpace(want))
102 }
103 body := data[off+len(want):]
104
105 streamAt := bytes.Index(body, []byte("stream\n"))
106 endObj := bytes.Index(body, []byte("\nendobj"))
107 if streamAt < 0 || (endObj >= 0 && endObj < streamAt) {
108 if endObj < 0 {
109 t.Fatalf("object %d: no endobj", num)
110 }
111 return pdfObject{dict: string(body[:endObj])}
112 }
113
114 dict := string(body[:streamAt])
115 m := lengthRe.FindStringSubmatch(dict)
116 if m == nil {
117 t.Fatalf("object %d: a stream with no /Length", num)
118 }
119 n, _ := strconv.Atoi(m[1])
120 start := streamAt + len("stream\n")
121 if start+n > len(body) {
122 t.Fatalf("object %d: /Length %d runs past the end of the file", num, n)
123 }
124 raw := body[start : start+n]
125
126 if !strings.Contains(dict, "/FlateDecode") {
127 return pdfObject{dict: dict, stream: raw}
128 }
129 zr, err := zlib.NewReader(bytes.NewReader(raw))
130 if err != nil {
131 t.Fatalf("object %d: stream is not valid zlib: %v", num, err)
132 }
133 plain, err := io.ReadAll(zr)
134 if err != nil {
135 t.Fatalf("object %d: decompressing: %v", num, err)
136 }
137 return pdfObject{dict: dict, stream: plain}
138}
139
140func firstLine(b []byte) string {
141 if i := bytes.IndexByte(b, '\n'); i >= 0 {
142 b = b[:i]
143 }
144 if len(b) > 60 {
145 b = b[:60]
146 }
147 return string(b)
148}
149
150// pages returns every page object's dictionary, in /Kids order.
151func (f pdfFile) pages(t *testing.T) []pdfObject {
152 t.Helper()
153 var pagesDict string
154 for _, o := range f.objects {
155 if strings.Contains(o.dict, "/Type /Pages") {
156 pagesDict = o.dict
157 }
158 }
159 if pagesDict == "" {
160 t.Fatal("no /Type /Pages node")
161 }
162 kids := regexp.MustCompile(`(\d+) 0 R`).FindAllStringSubmatch(
163 betweenBrackets(t, pagesDict, "/Kids ["), -1)
164 var out []pdfObject
165 for _, k := range kids {
166 n, _ := strconv.Atoi(k[1])
167 o, ok := f.objects[n]
168 if !ok {
169 t.Fatalf("/Kids names object %d, which the xref does not list", n)
170 }
171 if !strings.Contains(o.dict, "/Type /Page") {
172 t.Fatalf("object %d is in /Kids but is not a /Page", n)
173 }
174 out = append(out, o)
175 }
176 return out
177}
178
179func betweenBrackets(t *testing.T, s, open string) string {
180 t.Helper()
181 i := strings.Index(s, open)
182 if i < 0 {
183 t.Fatalf("%q not found in %q", open, s)
184 }
185 rest := s[i+len(open):]
186 j := strings.Index(rest, "]")
187 if j < 0 {
188 t.Fatalf("unterminated %q", open)
189 }
190 return rest[:j]
191}
192
193var (
194 fontResRe = regexp.MustCompile(`/(F\d+) (\d+) 0 R`)
195 contentsRe = regexp.MustCompile(`/Contents (\d+) 0 R`)
196 toUnicodeRe = regexp.MustCompile(`/ToUnicode (\d+) 0 R`)
197 bfcharRe = regexp.MustCompile(`<([0-9A-Fa-f]+)> <([0-9A-Fa-f]+)>`)
198 // One drawn run, exactly as internal/pdf's Page.Text writes it:
199 // the text matrix carries the baseline position, so the x and y a
200 // run actually landed at are readable here and nowhere else.
201 showRe = regexp.MustCompile(
202 `/(F\d+) [\d.-]+ Tf 1 0 0 1 ([\d.-]+) ([\d.-]+) Tm <([0-9A-Fa-f]*)> Tj`)
203 // One filled rectangle (Page.Rule) — the blockquote's edge is the
204 // only non-text drawing this exporter does.
205 ruleRe = regexp.MustCompile(`([\d.-]+) ([\d.-]+) ([\d.-]+) ([\d.-]+) re f`)
206)
207
208// run is one drawn piece of text with where it was drawn: x and y are
209// the baseline's own coordinates, in points from the bottom-left of the
210// page, which is PDF's origin and not the layout's.
211type run struct {
212 x, y float64
213 text string
214}
215
216// cmaps maps each font resource named by a page to its own ToUnicode
217// table — the glyph-id-to-rune mapping a reader uses to let a person
218// copy text out.
219func (f pdfFile) cmaps(t *testing.T, page pdfObject) map[string]map[uint16]rune {
220 t.Helper()
221 out := map[string]map[uint16]rune{}
222 for _, m := range fontResRe.FindAllStringSubmatch(page.dict, -1) {
223 n, _ := strconv.Atoi(m[2])
224 fontObj, ok := f.objects[n]
225 if !ok {
226 t.Fatalf("page resources name font object %d, which does not exist", n)
227 }
228 tu := toUnicodeRe.FindStringSubmatch(fontObj.dict)
229 if tu == nil {
230 t.Fatalf("font %s has no /ToUnicode — its text cannot be copied out", m[1])
231 }
232 tn, _ := strconv.Atoi(tu[1])
233 cmap := map[uint16]rune{}
234 for _, e := range bfcharRe.FindAllStringSubmatch(string(f.objects[tn].stream), -1) {
235 gid, _ := strconv.ParseUint(e[1], 16, 16)
236 cmap[uint16(gid)] = decodeUTF16BE(e[2])
237 }
238 out[m[1]] = cmap
239 }
240 return out
241}
242
243func (f pdfFile) content(t *testing.T, page pdfObject) string {
244 t.Helper()
245 cm := contentsRe.FindStringSubmatch(page.dict)
246 if cm == nil {
247 t.Fatal("page has no /Contents")
248 }
249 cn, _ := strconv.Atoi(cm[1])
250 return string(f.objects[cn].stream)
251}
252
253// runs returns every drawn piece of text on the page WITH its baseline
254// position, in the order the content stream draws them.
255//
256// text below throws the positions away, which is why a whole-page
257// horizontal offset was invisible to every test in this package until
258// this existed: the words all copied back out correctly, they were just
259// drawn in the margin.
260func (f pdfFile) runs(t *testing.T, page pdfObject) []run {
261 t.Helper()
262 cmaps := f.cmaps(t, page)
263
264 var out []run
265 for _, m := range showRe.FindAllStringSubmatch(f.content(t, page), -1) {
266 cmap, ok := cmaps[m[1]]
267 if !ok {
268 t.Fatalf("content stream uses font %s, which the page resources do not declare", m[1])
269 }
270 x, err := strconv.ParseFloat(m[2], 64)
271 if err != nil {
272 t.Fatalf("unreadable x in the text matrix: %q", m[2])
273 }
274 y, err := strconv.ParseFloat(m[3], 64)
275 if err != nil {
276 t.Fatalf("unreadable y in the text matrix: %q", m[3])
277 }
278 var b strings.Builder
279 hex := m[4]
280 for i := 0; i+4 <= len(hex); i += 4 {
281 gid, _ := strconv.ParseUint(hex[i:i+4], 16, 16)
282 r, ok := cmap[uint16(gid)]
283 if !ok {
284 t.Errorf("glyph %d is drawn but has no ToUnicode entry", gid)
285 continue
286 }
287 b.WriteRune(r)
288 }
289 out = append(out, run{x: x, y: y, text: b.String()})
290 }
291 return out
292}
293
294// rect is one filled rectangle, in PDF coordinates.
295type rect struct{ x, y, w, h float64 }
296
297// rules returns every filled rectangle on the page — the blockquote
298// edge, which is drawn at an x of its own and would sit in the margin
299// alongside the text if that x were wrong.
300func (f pdfFile) rules(t *testing.T, page pdfObject) []rect {
301 t.Helper()
302 var out []rect
303 for _, m := range ruleRe.FindAllStringSubmatch(f.content(t, page), -1) {
304 var r rect
305 for i, dst := range []*float64{&r.x, &r.y, &r.w, &r.h} {
306 v, err := strconv.ParseFloat(m[i+1], 64)
307 if err != nil {
308 t.Fatalf("unreadable rectangle coordinate %q", m[i+1])
309 }
310 *dst = v
311 }
312 out = append(out, r)
313 }
314 return out
315}
316
317// text reconstructs what a person would get by selecting everything on
318// the page and copying it: the glyph ids in the content stream, mapped
319// back through each font's own ToUnicode CMap. If that mapping is
320// wrong or missing, this returns mojibake, which is exactly what a
321// reader would show.
322func (f pdfFile) text(t *testing.T, page pdfObject) string {
323 t.Helper()
324 var b strings.Builder
325 for _, r := range f.runs(t, page) {
326 b.WriteString(r.text)
327 }
328 return b.String()
329}
330
331func decodeUTF16BE(hex string) rune {
332 var units []rune
333 for i := 0; i+4 <= len(hex); i += 4 {
334 v, _ := strconv.ParseUint(hex[i:i+4], 16, 16)
335 units = append(units, rune(v))
336 }
337 if len(units) == 2 && units[0] >= 0xD800 && units[0] <= 0xDBFF {
338 return ((units[0] - 0xD800) << 10) + (units[1] - 0xDC00) + 0x10000
339 }
340 if len(units) == 0 {
341 return 0
342 }
343 return units[0]
344}
345