chiark / gitweb /
It occurred to me yesterday that Net could perfectly well be played
[sgt-puzzles.git] / print.py
1 #!/usr/bin/env python
2
3 # This program accepts a series of newline-separated game IDs on
4 # stdin and formats them into PostScript to be printed out. You
5 # specify using command-line options which game the IDs are for,
6 # and how many you want per page.
7
8 # Supported games are those which are sensibly solvable using
9 # pencil and paper: Rectangles, Pattern, Solo, Net.
10
11 # Command-line syntax is
12 #
13 #     print.py <game-name> <format>
14 #
15 # <game-name> is one of `rect', `rectangles', `pattern', `solo',
16 # `net'. <format> is two numbers separated by an x: `2x3', for
17 # example, means two columns by three rows.
18 #
19 # The program will then read game IDs from stdin until it sees EOF,
20 # and generate as many PostScript pages on stdout as it needs.
21 #
22 # The resulting PostScript will automatically adapt itself to the
23 # size of the clip rectangle, so that the puzzles are sensibly
24 # distributed across whatever paper size you decide to use.
25
26 import sys
27 import string
28 import re
29
30 class Holder:
31     pass
32
33 def psvprint(h, a):
34     for i in xrange(len(a)):
35         h.s = h.s + str(a[i])
36         if i < len(a)-1:
37             h.s = h.s + " "
38         else:
39             h.s = h.s + "\n"
40
41 def psprint(h, *a):
42     psvprint(h, a)
43
44 def rect_format(s):
45     # Parse the game ID.
46     ret = Holder()
47     ret.s = ""
48     params, seed = string.split(s, ":")
49     w, h = map(string.atoi, string.split(params, "x"))
50     grid = []
51     while len(seed) > 0:
52         if seed[0] in '_'+string.lowercase:
53             if seed[0] in string.lowercase:
54                 grid.extend([-1] * (ord(seed[0]) - ord('a') + 1))
55             seed = seed[1:]
56         elif seed[0] in string.digits:
57             ns = ""
58             while len(seed) > 0 and seed[0] in string.digits:
59                 ns = ns + seed[0]
60                 seed = seed[1:]
61             grid.append(string.atoi(ns))
62     assert w * h == len(grid)
63     # I'm going to arbitrarily choose to use 7pt text for the
64     # numbers, and a 14pt grid pitch.
65     textht = 7
66     gridpitch = 14
67     # Set up coordinate system.
68     pw = gridpitch * w
69     ph = gridpitch * h
70     ret.coords = (pw/2, pw/2, ph/2, ph/2)
71     psprint(ret, "%g %g translate" % (-ret.coords[0], -ret.coords[2]))
72     # Draw the internal grid lines, _very_ thin (the player will
73     # need to draw over them visibly).
74     psprint(ret, "newpath 0.01 setlinewidth")
75     for x in xrange(1,w):
76         psprint(ret, "%g 0 moveto 0 %g rlineto" % (x * gridpitch, h * gridpitch))
77     for y in xrange(1,h):
78         psprint(ret, "0 %g moveto %g 0 rlineto" % (y * gridpitch, w * gridpitch))
79     psprint(ret, "stroke")
80     # Draw round the grid exterior, much thicker.
81     psprint(ret, "newpath 1.5 setlinewidth")
82     psprint(ret, "0 0 moveto 0 %g rlineto %g 0 rlineto 0 %g rlineto" % \
83     (h * gridpitch, w * gridpitch, -h * gridpitch))
84     psprint(ret, "closepath stroke")
85     # And draw the numbers.
86     psprint(ret, "/Helvetica findfont %g scalefont setfont" % textht)
87     for y in xrange(h):
88         for x in xrange(w):
89             n = grid[y*w+x]
90             if n > 0:
91                 psprint(ret, "%g %g (%d) ctshow" % \
92                 ((x+0.5)*gridpitch, (h-y-0.5)*gridpitch, n))
93     return ret.coords, ret.s
94
95 def net_format(s):
96     # Parse the game ID.
97     ret = Holder()
98     ret.s = ""
99     params, seed = string.split(s, ":")
100     wrapping = 0
101     if params[-1:] == "w":
102         wrapping = 1
103         params = params[:-1]
104     w, h = map(string.atoi, string.split(params, "x"))
105     grid = []
106     hbarriers = []
107     vbarriers = []
108     while len(seed) > 0:
109         n = string.atoi(seed[0], 16)
110         seed = seed[1:]
111         while len(seed) > 0 and seed[0] in 'hv':
112             x = len(grid) % w
113             y = len(grid) / w
114             if seed[0] == 'h':
115                 hbarriers.append((x, y+1))
116             else:
117                 vbarriers.append((x+1, y))
118             seed = seed[1:]
119         grid.append(n)
120     assert w * h == len(grid)
121     # I'm going to arbitrarily choose a 24pt grid pitch.
122     gridpitch = 24
123     scale = 0.25
124     bigoffset = 0.25
125     smalloffset = 0.17
126     # Set up coordinate system.
127     pw = gridpitch * w
128     ph = gridpitch * h
129     ret.coords = (pw/2, pw/2, ph/2, ph/2)
130     psprint(ret, "%g %g translate" % (-ret.coords[0], -ret.coords[2]))
131     # Draw the base grid lines.
132     psprint(ret, "newpath 0.02 setlinewidth")
133     for x in xrange(1,w):
134         psprint(ret, "%g 0 moveto 0 %g rlineto" % (x * gridpitch, h * gridpitch))
135     for y in xrange(1,h):
136         psprint(ret, "0 %g moveto %g 0 rlineto" % (y * gridpitch, w * gridpitch))
137     psprint(ret, "stroke")
138     # Draw round the grid exterior.
139     psprint(ret, "newpath")
140     if not wrapping:
141         psprint(ret, "2 setlinewidth")
142     psprint(ret, "0 0 moveto 0 %g rlineto %g 0 rlineto 0 %g rlineto" % \
143     (h * gridpitch, w * gridpitch, -h * gridpitch))
144     psprint(ret, "closepath stroke")
145     # Draw any barriers.
146     psprint(ret, "newpath 2 setlinewidth 1 setlinecap")
147     for x, y in hbarriers:
148         psprint(ret, "%g %g moveto %g 0 rlineto" % \
149         (x * gridpitch, (h - y) * gridpitch, gridpitch))
150     for x, y in vbarriers:
151         psprint(ret, "%g %g moveto 0 -%g rlineto" % \
152         (x * gridpitch, (h - y) * gridpitch, gridpitch))
153     psprint(ret, "stroke")
154     # And draw the symbol in each box.
155     for i in xrange(len(grid)):
156         x = i % w
157         y = i / w
158         v = grid[i]
159         # Rotate to canonical form.
160         if v in (1,2,4,8):
161             v = 1
162         elif v in (5,10):
163             v = 5
164         elif v in (3,6,9,12):
165             v = 9
166         elif v in (7,11,13,14):
167             v = 13
168         # Centre on an area in the corner of the tile.
169         psprint(ret, "gsave")
170         if v & 4:
171             hoffset = bigoffset
172         else:
173             hoffset = smalloffset
174         if v & 2:
175             voffset = bigoffset
176         else:
177             voffset = smalloffset
178         psprint(ret, "%g %g translate" % \
179         ((x + hoffset) * gridpitch, (h - y - voffset) * gridpitch))
180         psprint(ret, "%g dup scale" % (float(gridpitch) * scale / 2))
181         psprint(ret, "newpath 0.07 setlinewidth")
182         # Draw the radial lines.
183         for dx, dy, z in ((1,0,1), (0,1,2), (-1,0,4), (0,-1,8)):
184             if v & z:
185                 psprint(ret, "0 0 moveto %d %d lineto" % (dx, dy))
186         psprint(ret, "stroke")
187         # Draw additional figures if desired.
188         if v == 13:
189             # T-pieces have a little circular blob where the lines join.
190             psprint(ret, "newpath 0 0 0.15 0 360 arc fill")
191         elif v == 1:
192             # Endpoints have a little empty square at the centre.
193             psprint(ret, "newpath 0.35 0.35 moveto 0 -0.7 rlineto")
194             psprint(ret, "-0.7 0 rlineto 0 0.7 rlineto closepath")
195             psprint(ret, "gsave 1 setgray fill grestore stroke")
196         # Clean up.
197         psprint(ret, "grestore")
198     return ret.coords, ret.s
199
200 def pattern_format(s):
201     ret = Holder()
202     ret.s = ""
203     # Parse the game ID.
204     params, seed = string.split(s, ":")
205     w, h = map(string.atoi, string.split(params, "x"))
206     rowdata = map(lambda s: string.split(s, "."), string.split(seed, "/"))
207     assert len(rowdata) == w+h
208     # I'm going to arbitrarily choose to use 7pt text for the
209     # numbers, and a 14pt grid pitch.
210     textht = 7
211     gridpitch = 14
212     gutter = 8 # between the numbers and the grid
213     # Find the maximum number of numbers in each dimension, to
214     # determine the border size required.
215     xborder = reduce(max, map(len, rowdata[w:]))
216     yborder = reduce(max, map(len, rowdata[:w]))
217     # Set up coordinate system. I'm going to put the origin at the
218     # _top left_ of the grid, so that both sets of numbers get
219     # drawn the same way.
220     pw = (w + xborder) * gridpitch + gutter
221     ph = (h + yborder) * gridpitch + gutter
222     ret.coords = (xborder * gridpitch + gutter, w * gridpitch, \
223     yborder * gridpitch + gutter, h * gridpitch)
224     # Draw the internal grid lines. Every fifth one is thicker, as
225     # a visual aid.
226     psprint(ret, "newpath 0.1 setlinewidth")
227     for x in xrange(1,w):
228         if x % 5 != 0:
229             psprint(ret, "%g 0 moveto 0 %g rlineto" % (x * gridpitch, -h * gridpitch))
230     for y in xrange(1,h):
231         if y % 5 != 0:
232             psprint(ret, "0 %g moveto %g 0 rlineto" % (-y * gridpitch, w * gridpitch))
233     psprint(ret, "stroke")
234     psprint(ret, "newpath 0.75 setlinewidth")
235     for x in xrange(5,w,5):
236         psprint(ret, "%g 0 moveto 0 %g rlineto" % (x * gridpitch, -h * gridpitch))
237     for y in xrange(5,h,5):
238         psprint(ret, "0 %g moveto %g 0 rlineto" % (-y * gridpitch, w * gridpitch))
239     psprint(ret, "stroke")
240     # Draw round the grid exterior.
241     psprint(ret, "newpath 1.5 setlinewidth")
242     psprint(ret, "0 0 moveto 0 %g rlineto %g 0 rlineto 0 %g rlineto" % \
243     (-h * gridpitch, w * gridpitch, h * gridpitch))
244     psprint(ret, "closepath stroke")
245     # And draw the numbers.
246     psprint(ret, "/Helvetica findfont %g scalefont setfont" % textht)
247     for i in range(w+h):
248         ns = rowdata[i]
249         if i < w:
250             xo = (i + 0.5) * gridpitch
251             yo = (gutter + 0.5 * gridpitch)
252         else:
253             xo = -(gutter + 0.5 * gridpitch)
254             yo = ((i-w) + 0.5) * -gridpitch
255         for j in range(len(ns)-1, -1, -1):
256             psprint(ret, "%g %g (%s) ctshow" % (xo, yo, ns[j]))
257             if i < w:
258                 yo = yo + gridpitch
259             else:
260                 xo = xo - gridpitch
261     return ret.coords, ret.s
262
263 def solo_format(s):
264     ret = Holder()
265     ret.s = ""
266     # Parse the game ID.
267     params, seed = string.split(s, ":")
268     c, r = map(string.atoi, string.split(params, "x"))
269     cr = c*r
270     grid = []
271     while len(seed) > 0:
272         if seed[0] in '_'+string.lowercase:
273             if seed[0] in string.lowercase:
274                 grid.extend([-1] * (ord(seed[0]) - ord('a') + 1))
275             seed = seed[1:]
276         elif seed[0] in string.digits:
277             ns = ""
278             while len(seed) > 0 and seed[0] in string.digits:
279                 ns = ns + seed[0]
280                 seed = seed[1:]
281             grid.append(string.atoi(ns))
282     assert cr * cr == len(grid)
283     # I'm going to arbitrarily choose to use 9pt text for the
284     # numbers, and a 16pt grid pitch.
285     textht = 9
286     gridpitch = 16
287     # Set up coordinate system.
288     pw = ph = gridpitch * cr
289     ret.coords = (pw/2, pw/2, ph/2, ph/2)
290     psprint(ret, "%g %g translate" % (-ret.coords[0], -ret.coords[2]))
291     # Draw the thin internal grid lines.
292     psprint(ret, "newpath 0.1 setlinewidth")
293     for x in xrange(1,cr):
294         if x % r != 0:
295             psprint(ret, "%g 0 moveto 0 %g rlineto" % (x * gridpitch, cr * gridpitch))
296     for y in xrange(1,cr):
297         if y % c != 0:
298             psprint(ret, "0 %g moveto %g 0 rlineto" % (y * gridpitch, cr * gridpitch))
299     psprint(ret, "stroke")
300     # Draw the thicker internal grid lines.
301     psprint(ret, "newpath 1 setlinewidth")
302     for x in xrange(r,cr,r):
303         psprint(ret, "%g 0 moveto 0 %g rlineto" % (x * gridpitch, cr * gridpitch))
304     for y in xrange(c,cr,c):
305         psprint(ret, "0 %g moveto %g 0 rlineto" % (y * gridpitch, cr * gridpitch))
306     psprint(ret, "stroke")
307     # Draw round the grid exterior, thicker still.
308     psprint(ret, "newpath 1.5 setlinewidth")
309     psprint(ret, "0 0 moveto 0 %g rlineto %g 0 rlineto 0 %g rlineto" % \
310     (cr * gridpitch, cr * gridpitch, -cr * gridpitch))
311     psprint(ret, "closepath stroke")
312     # And draw the numbers.
313     psprint(ret, "/Helvetica findfont %g scalefont setfont" % textht)
314     for y in xrange(cr):
315         for x in xrange(cr):
316             n = grid[y*cr+x]
317             if n > 0:
318                 if n > 9:
319                     s = chr(ord('a') + n - 10)
320                 else:
321                     s = chr(ord('0') + n)
322                 psprint(ret, "%g %g (%s) ctshow" % \
323                 ((x+0.5)*gridpitch, (cr-y-0.5)*gridpitch, s))
324     return ret.coords, ret.s
325
326 formatters = {
327 "net": net_format,
328 "rect": rect_format,
329 "rectangles": rect_format,
330 "pattern": pattern_format,
331 "solo": solo_format
332 }
333
334 if len(sys.argv) < 3:
335     sys.stderr.write("print.py: expected two arguments (game and format)\n")
336     sys.exit(1)
337
338 formatter = formatters.get(sys.argv[1], None)
339 if formatter == None:
340     sys.stderr.write("print.py: unrecognised game name `%s'\n" % sys.argv[1])
341     sys.exit(1)
342
343 try:
344     format = map(string.atoi, string.split(sys.argv[2], "x"))
345 except ValueError, e:
346     format = []
347 if len(format) != 2:
348     sys.stderr.write("print.py: expected format such as `2x3' as second" \
349     + " argument\n")
350     sys.exit(1)
351
352 xx, yy = format
353 ppp = xx * yy # puzzles per page
354
355 ids = []
356 while 1:
357     s = sys.stdin.readline()
358     if s == "": break
359     if s[-1:] == "\n": s = s[:-1]
360     ids.append(s)
361
362 pages = int((len(ids) + ppp - 1) / ppp)
363
364 # Output initial DSC stuff.
365 print "%!PS-Adobe-3.0"
366 print "%%Creator: print.py from Simon Tatham's Puzzle Collection"
367 print "%%DocumentData: Clean7Bit"
368 print "%%LanguageLevel: 1"
369 print "%%Pages:", pages
370 print "%%DocumentNeededResources:"
371 print "%%+ font Helvetica"
372 print "%%DocumentSuppliedResources: procset Puzzles 0 0"
373 print "%%EndComments"
374 print "%%BeginProlog"
375 print "%%BeginResource: procset Puzzles 0 0"
376 print "/ctshow {"
377 print "  3 1 roll"
378 print "  newpath 0 0 moveto (X) true charpath flattenpath pathbbox"
379 print "  3 -1 roll add 2 div 3 1 roll pop pop sub moveto"
380 print "  dup stringwidth pop 0.5 mul neg 0 rmoveto show"
381 print "} bind def"
382 print "%%EndResource"
383 print "%%EndProlog"
384 print "%%BeginSetup"
385 print "%%IncludeResource: font Helvetica"
386 print "%%EndSetup"
387
388 # Now do each page.
389 puzzle_index = 0;
390
391 for i in xrange(1, pages+1):
392     print "%%Page:", i, i
393     print "save"
394
395     # Do the drawing for each puzzle, giving a set of PS fragments
396     # and bounding boxes.
397     fragments = [['' for i in xrange(xx)] for i in xrange(yy)]
398     lrbound = [(0,0) for i in xrange(xx)]
399     tbbound = [(0,0) for i in xrange(yy)]
400
401     for y in xrange(yy):
402         for x in xrange(xx):
403             if puzzle_index >= len(ids):
404                 break
405             coords, frag = formatter(ids[puzzle_index])
406             fragments[y][x] = frag
407             lb, rb = lrbound[x]
408             lrbound[x] = (max(lb, coords[0]), max(rb, coords[1]))
409             tb, bb = tbbound[y]
410             tbbound[y] = (max(tb, coords[2]), max(bb, coords[3]))
411             puzzle_index = puzzle_index + 1
412
413     # Now we know the sizes of everything, do the drawing in such a
414     # way that we provide equal gutter space at the page edges and
415     # between puzzle rows/columns.
416     for y in xrange(yy):
417         for x in xrange(xx):
418             if len(fragments[y][x]) > 0:
419                 print "gsave"
420                 print "clippath flattenpath pathbbox pop pop translate"
421                 print "clippath flattenpath pathbbox 4 2 roll pop pop"
422                 # Compute the total height of all puzzles, which
423                 # we'll use it to work out the amount of gutter
424                 # space below this puzzle.
425                 htotal = reduce(lambda a,b:a+b, map(lambda (a,b):a+b, tbbound), 0)
426                 # Now compute the total height of all puzzles
427                 # _below_ this one, plus the height-below-origin of
428                 # this one.
429                 hbelow = reduce(lambda a,b:a+b, map(lambda (a,b):a+b, tbbound[y+1:]), 0)
430                 hbelow = hbelow + tbbound[y][1]
431                 print "%g sub %d mul %d div %g add exch" % (htotal, yy-y, yy+1, hbelow)
432                 # Now do all the same computations for width,
433                 # except we need the total width of everything
434                 # _before_ this one since the coordinates work the
435                 # other way round.
436                 wtotal = reduce(lambda a,b:a+b, map(lambda (a,b):a+b, lrbound), 0)
437                 # Now compute the total height of all puzzles
438                 # _below_ this one, plus the height-below-origin of
439                 # this one.
440                 wleft = reduce(lambda a,b:a+b, map(lambda (a,b):a+b, lrbound[:x]), 0)
441                 wleft = wleft + lrbound[x][0]
442                 print "%g sub %d mul %d div %g add exch" % (wtotal, x+1, xx+1, wleft)
443                 print "translate"
444                 sys.stdout.write(fragments[y][x])
445                 print "grestore"
446
447     print "restore showpage"
448
449 print "%%EOF"