oficina / slides Public

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

Plain git — no account needed to clone.

Download

Download this file

1package pdfcore_test
2
3import (
4 "bytes"
5 "compress/zlib"
6 "context"
7 "errors"
8 "fmt"
9 "io"
10 "regexp"
11 "strconv"
12 "strings"
13 "sync"
14 "testing"
15 "time"
16
17 "amadan.net/oficina/slides/internal/slides/fonts"
18 "amadan.net/oficina/slides/internal/slides/pdfcore"
19)
20
21// slide is one 1920 by 1080 page with a rectangle and a line of text in
22// a named face, which is the shape every page the SVG encoder writes
23// has.
24func slide(text, family string, weight int) []byte {
25 return fmt.Appendf(nil, `<svg xmlns="http://www.w3.org/2000/svg" width="1920" height="1080" viewBox="0 0 1920 1080">
26 <rect x="100" y="100" width="800" height="300" fill="#1e5aa8"/>
27 <text x="120" y="600" font-family="%s" font-weight="%d" font-size="96" fill="#111" xml:space="preserve">%s</text>
28</svg>`, family, weight, text)
29}
30
31// allFonts is what Export sends: the twelve Liberation faces.
32func allFonts() [][]byte {
33 var out [][]byte
34 for _, f := range fonts.All() {
35 out = append(out, fonts.Data(f))
36 }
37 return out
38}
39
40var runtimeOnce struct {
41 sync.Once
42 rt *pdfcore.Runtime
43 err error
44}
45
46// runtime compiles the module once for the whole package: compiling is
47// the expensive part (about three quarters of a second) and a Runtime is
48// safe to share, which is also what the app does.
49func runtime(t *testing.T) *pdfcore.Runtime {
50 t.Helper()
51 runtimeOnce.Do(func() {
52 runtimeOnce.rt, runtimeOnce.err = pdfcore.New(context.Background())
53 })
54 if runtimeOnce.err != nil {
55 t.Fatalf("New: %v", runtimeOnce.err)
56 }
57 return runtimeOnce.rt
58}
59
60// pageCount and its neighbours grep the PDF's bytes rather than parsing
61// it, so they take a dictionary entry in either spelling: pdf-writer 0.12
62// (which svg2pdf used) wrote "/Type /Page", and 0.15 (which krilla uses)
63// writes "/Type/Page". The object is the property under test, not the
64// space between the key and the value.
65func has(pdf []byte, entry string) bool {
66 return bytes.Contains(pdf, []byte(entry)) ||
67 bytes.Contains(pdf, []byte(strings.Replace(entry, " /", "/", 1)))
68}
69
70func countEither(pdf []byte, entry string) int {
71 return bytes.Count(pdf, []byte(entry)) +
72 bytes.Count(pdf, []byte(strings.Replace(entry, " /", "/", 1)))
73}
74
75func pageCount(pdf []byte) int {
76 return countEither(pdf, "/Type /Page") - countEither(pdf, "/Type /Pages")
77}
78
79func TestRenderMakesOnePagePerSVG(t *testing.T) {
80 rt := runtime(t)
81 pdf, err := rt.Render(context.Background(), [][]byte{slide("Hello slides", "Liberation Serif", 400)}, allFonts())
82 if err != nil {
83 t.Fatalf("Render: %v", err)
84 }
85 if !bytes.HasPrefix(pdf, []byte("%PDF-")) {
86 t.Fatalf("not a PDF: %q", pdf[:min(16, len(pdf))])
87 }
88 if n := pageCount(pdf); n != 1 {
89 t.Errorf("pages = %d, want 1", n)
90 }
91 if !bytes.Contains(pdf, []byte("/MediaBox [0 0 1920 1080]")) &&
92 !bytes.Contains(pdf, []byte("/MediaBox[0 0 1920 1080]")) {
93 t.Error("the page is not 1920 by 1080 pt")
94 }
95}
96
97func TestRenderTwoSVGsMakeTwoPages(t *testing.T) {
98 rt := runtime(t)
99 pdf, err := rt.Render(context.Background(), [][]byte{
100 slide("One", "Liberation Sans", 400),
101 slide("Two", "Liberation Sans", 400),
102 }, allFonts())
103 if err != nil {
104 t.Fatalf("Render: %v", err)
105 }
106 if n := pageCount(pdf); n != 2 {
107 t.Errorf("pages = %d, want 2", n)
108 }
109 if !bytes.Contains(pdf, []byte("/Count 2")) {
110 t.Error("the page tree does not count 2")
111 }
112}
113
114func TestRenderEmbedsTheFacesItWasGivenAsSubsets(t *testing.T) {
115 rt := runtime(t)
116 pdf, err := rt.Render(context.Background(), [][]byte{
117 slide("Serif here", "Liberation Serif", 400),
118 slide("Bold sans here", "Liberation Sans", 700),
119 }, allFonts())
120 if err != nil {
121 t.Fatalf("Render: %v", err)
122 }
123 if !bytes.Contains(pdf, []byte("/FontFile2")) {
124 t.Error("no embedded TrueType program (/FontFile2)")
125 }
126 if !has(pdf, "/Subtype /Type0") {
127 t.Error("no Type0 font: text was flattened to paths")
128 }
129 if !bytes.Contains(pdf, []byte("/ToUnicode")) {
130 t.Error("no ToUnicode CMap: the text would not extract")
131 }
132 // A subset carries a six-letter tag before the face's name, and the
133 // bold weight resolves to the bold face rather than a synthesised
134 // one.
135 for _, face := range []string{"LiberationSerif", "LiberationSans-Bold"} {
136 if !regexp.MustCompile(`/BaseFont ?/[A-Z]{6}\+` + face).Match(pdf) {
137 t.Errorf("no subset of %s in the PDF", face)
138 }
139 }
140 // The whole faces are 4 MB; a subset of two short lines is not.
141 if len(pdf) > 200_000 {
142 t.Errorf("PDF is %d bytes: the faces were embedded whole, not subset", len(pdf))
143 }
144 // The words are drawn with a text operator, not filled as paths.
145 if plain := inflate(t, pdf); !bytes.Contains(plain, []byte("Tj")) && !bytes.Contains(plain, []byte("TJ")) {
146 t.Error("no Tj/TJ in any content stream")
147 }
148}
149
150func TestAMalformedSVGIsAnErrorNotATrap(t *testing.T) {
151 rt := runtime(t)
152 ctx := context.Background()
153 _, err := rt.Render(ctx, [][]byte{[]byte(`<svg xmlns="http://www.w3.org/2000/svg" width="1920" height="1080"><rect x="1`)}, allFonts())
154 if err == nil {
155 t.Fatal("a malformed SVG rendered")
156 }
157 if !pdfcore.Rejected(err) {
158 t.Errorf("err = %v; want a clean refusal (Rejected)", err)
159 }
160 if !strings.Contains(err.Error(), "page 1") {
161 t.Errorf("err = %v; want it to name the page", err)
162 }
163
164 // Nothing shared between calls: the next render is untouched by the
165 // last one's refusal.
166 if _, err := rt.Render(ctx, [][]byte{slide("Still fine", "Liberation Sans", 400)}, allFonts()); err != nil {
167 t.Fatalf("render after a refusal: %v", err)
168 }
169}
170
171func TestNoPagesIsRefused(t *testing.T) {
172 rt := runtime(t)
173 _, err := rt.Render(context.Background(), nil, allFonts())
174 if err == nil || !pdfcore.Rejected(err) {
175 t.Fatalf("err = %v; want a refusal", err)
176 }
177}
178
179func TestADeadlineIsAnErrorNotAHang(t *testing.T) {
180 rt, err := pdfcore.New(context.Background(), pdfcore.WithGuestDeadline(time.Nanosecond))
181 if err != nil {
182 t.Fatalf("New: %v", err)
183 }
184 defer rt.Close(context.Background())
185 _, err = rt.Render(context.Background(), [][]byte{slide("Slow", "Liberation Sans", 400)}, allFonts())
186 if err == nil {
187 t.Fatal("rendered under a nanosecond deadline")
188 }
189 if pdfcore.Rejected(err) {
190 t.Errorf("a deadline is not a clean refusal: %v", err)
191 }
192 if !errors.Is(err, context.DeadlineExceeded) {
193 t.Errorf("err = %v; want context.DeadlineExceeded", err)
194 }
195}
196
197// A render that runs out of memory is an error like any other guest
198// failure, not a crash: the module's allocator aborts, wazero reports a
199// trap, and the instance dies alone.
200//
201// 32 pages is 2 MiB — just above the module's own static memory (25
202// pages since the krilla swap, which is why this is not the 24 it was)
203// and far below what one page of krilla-svg needs, so the ceiling is
204// reached on the first render instead of after allocating half a
205// gibibyte to find the real one (defaultMemoryPages). The same page at
206// the default limit is rendered afterwards, so this cannot pass on a
207// broken SVG.
208func TestARenderPastTheMemoryLimitIsAnErrorNotACrash(t *testing.T) {
209 ctx := context.Background()
210 page := slide("Over the top", "Liberation Sans", 400)
211
212 tight, err := pdfcore.New(ctx, pdfcore.WithMemoryLimitPages(32)) // 2 MiB
213 if err != nil {
214 t.Fatalf("New with a 32-page limit: %v", err)
215 }
216 defer tight.Close(ctx)
217 out, err := tight.Render(ctx, [][]byte{page}, allFonts())
218 if err == nil {
219 t.Fatalf("rendered %d bytes under a 32-page memory limit; the limit is not armed", len(out))
220 }
221 if pdfcore.Rejected(err) {
222 t.Errorf("running out of memory reads as a refusal of the deck: %v", err)
223 }
224 t.Logf("over the limit: %v", err)
225
226 if _, err := runtime(t).Render(ctx, [][]byte{page}, allFonts()); err != nil {
227 t.Fatalf("the same page at the default limit: %v", err)
228 }
229}
230
231func TestACancelledContextIsAnError(t *testing.T) {
232 rt := runtime(t)
233 ctx, cancel := context.WithCancel(context.Background())
234 cancel()
235 if _, err := rt.Render(ctx, [][]byte{slide("Gone", "Liberation Sans", 400)}, allFonts()); err == nil {
236 t.Fatal("rendered under a cancelled context")
237 }
238}
239
240func TestRendersAreIndependentAndConcurrent(t *testing.T) {
241 rt := runtime(t)
242 var wg sync.WaitGroup
243 errs := make([]error, 8)
244 for i := range errs {
245 wg.Add(1)
246 go func() {
247 defer wg.Done()
248 pdf, err := rt.Render(context.Background(), [][]byte{slide(fmt.Sprintf("Page %d", i), "Liberation Sans", 400)}, allFonts())
249 if err == nil && pageCount(pdf) != 1 {
250 err = fmt.Errorf("%d pages", pageCount(pdf))
251 }
252 errs[i] = err
253 }()
254 }
255 wg.Wait()
256 for i, err := range errs {
257 if err != nil {
258 t.Errorf("render %d: %v", i, err)
259 }
260 }
261}
262
263func TestTwoHundredPagesRenderInUnderTenSeconds(t *testing.T) {
264 rt := runtime(t)
265 svgs := make([][]byte, 200)
266 for i := range svgs {
267 svgs[i] = slide(fmt.Sprintf("Slide %d of two hundred", i+1), "Liberation Sans", 400)
268 }
269 start := time.Now()
270 pdf, err := rt.Render(context.Background(), svgs, allFonts())
271 took := time.Since(start)
272 if err != nil {
273 t.Fatalf("Render: %v", err)
274 }
275 if n := pageCount(pdf); n != 200 {
276 t.Errorf("pages = %d, want 200", n)
277 }
278 if took > 10*time.Second {
279 t.Errorf("200 pages took %s, want under 10s", took)
280 }
281 t.Logf("200 pages: %s, %d bytes", took.Round(time.Millisecond), len(pdf))
282}
283
284// inflate returns pdf with every FlateDecode stream replaced by its
285// plain bytes, so text operators can be looked for. Streams are found
286// by the direct /Length pdf-writer always writes.
287func inflate(t *testing.T, pdf []byte) []byte {
288 t.Helper()
289 var out []byte
290 marker := []byte(">>\nstream\n")
291 pos := 0
292 for {
293 i := bytes.Index(pdf[pos:], marker)
294 if i < 0 {
295 break
296 }
297 dictEnd := pos + i
298 start := dictEnd + len(marker)
299 dictStart := bytes.LastIndex(pdf[:dictEnd], []byte(" 0 obj"))
300 if dictStart < 0 {
301 dictStart = 0
302 }
303 dict := pdf[dictStart:dictEnd]
304 j := bytes.Index(dict, []byte("/Length "))
305 if j < 0 {
306 t.Fatal("stream dictionary without /Length")
307 }
308 digits := dict[j+8:]
309 k := 0
310 for k < len(digits) && digits[k] >= '0' && digits[k] <= '9' {
311 k++
312 }
313 n, err := strconv.Atoi(string(digits[:k]))
314 if err != nil {
315 t.Fatal(err)
316 }
317 end := start + n
318 out = append(out, pdf[pos:start]...)
319 raw := pdf[start:end]
320 if bytes.Contains(dict, []byte("FlateDecode")) {
321 if z, err := zlib.NewReader(bytes.NewReader(raw)); err == nil {
322 if plain, err := io.ReadAll(z); err == nil {
323 out = append(out, plain...)
324 pos = end
325 continue
326 }
327 }
328 }
329 out = append(out, raw...)
330 pos = end
331 }
332 return append(out, pdf[pos:]...)
333}
334