March 2024
December 2024
PostScript is a compact, simple, and stack-based interpreted language. It is the precursor to PDF, yet it is significantly more powerful. As a Turing-complete language, it can theoretically compute anything that another Turing-complete language can.
Undoubtedly, PostScript is considered outdated. It's not intended to be used directly by humans, but to be machine-generated and interpreted on printers. There are no real Integrated Development Environments (IDEs) for it, nor are there any substantial debuggers. PostScript lacks a standard method for checking the argument types and return values of procedures. No standard libraries are available. Moreover, the PostScript language has been released in three official versions, and interpreters may also include specific instructions.
Despite these drawbacks, PostScript is stunningly fun to work with. Indeed, engineering is fundamentally about building things up within constraints, devising relevant guidelines and conventions. The lack of complicated language constructs, or huge libs and framework to master, in combination with its vintage charm, simplicity and powerful set of primitives make PostScript an ideal candidate for software engineering pet projects such as PSChess.
This page compiles some of the aspects and techniques I employ when writing PostScript for enjoyment. It is by no means comprehensive and there may be areas for correction or improvement. If you are someone who still enjoys manually coding in PostScript, I would greatly appreciate your feedback.
Everything pretty much comes down to piling and unpiling values, and storing values in dictionaries.
% put some values in dictionary
/a 3 def
/b 1 def
% put some values on stack
2 5 (asd)
% operators take values on top of stack and through dictionary keys
length % 3
add % 8
a sub % 5
b add % 6
sub % 4
I couldn't find a decent PostScript IDE on macOS. Here are the options I've considered, from best to worst:
One of the very first thing we need when programming in PostScript is logs. When the interpreter has a console, which is the case for GhostScript, we can use the following operators, often followed by quit
. The last two are GhostScript specific, though:
print
=
==
=only
==only
In practice, it is very common to add such markers in code to follow what's going on:
(--1) =
(--2) =
The stack can be printed with pstack
.
The lineedit
operator allows reading user input.
PostScript Language Reference Manual mentions that this operator may not be available on all interpreters.
%!PS
/printUsage {
(Usage:) =
( a - some stuff) =
( b - other stuff) =
( q - quit) =
} def
printUsage
{
/userInput 128 string def
(>) print flush
(%lineedit) (r) file userInput readline
pop % bool
token not { () } if
{
dup (q) eq { quit } if
dup (a) eq {
pop token pop
(a) =
exit
} if
dup (b) eq {
pop token pop
(b) =
exit
} if
clear
printUsage
exit
} loop
} loop
You can get a prompt on a real laser printer by sending this program with netcat:
$ nc 172.16.158.40 9100
(copy-paste the program here)
Handling immediate keyboard input (without hiting enter
) is a bit more subtle. The idea is to have the PostScript program read single characters from standard input.
Here is how if goes using GhostView. Two terminals are used:
- First terminal sets up a named pipe (/tmp/p
) and pipes both the PostScript code and the pipe content to Ghostview
- Second terminal is configured for raw input (no line buffering, no echo) and writes directly to the named pipe
Example:
{
(%stdin) (r) file read {
== flush
} if
} loop
Terminal 1: read pipe
rm /tmp/p; mkfifo /tmp/p; cat x.ps /tmp/p | gv -
Terminal 2: fill pipe
stty raw -echo; cat > /tmp/p
Reference: http://quickies.seriot.ch/?id=604
This is the technique I used to write PSSokoban.
PostScript procedures can pop and push values on the stack. To make this clear, I've come up with the following convention:
Example of PostScript procedure name:
_board_fromCoord_toCoord_Move_canMove_
Functional equivalent:
move(board, fromCoord, toCoord) -> canMove
String are assigned by reference. They must be copied whenever needed. In practice, I've found not copying strings to be the root cause of many bugs.
In Python:
s1 = "asd"
s2 = s1 # copy
s1 += "x"
print(s1) # asdx
print(s2) # asd
In PostScript:
/s1 (aa) def
/s2 s1 def % s2 is same as s1
/s3 s1 dup length string cvs def % s3 is s1 copy
s1 1 (b) putinterval % modify s1
s1 = % ab % s1 has changed
s2 = % ab % s2 (s1) has changed
s3 = % aa % s3 hasn't changed
PostScript has no stack frame to store local variables. Variables are stored as key-value pairs in the top-most dictionary. By default, variables declared inside a procedure may override variables declared outside of the procedure, and will remain available outside of it.
Example:
/x 1 def % declares x = 1
/p { % procedure p
/x 2 def % declares x = 2
x = % prints 2
} def
x = % print 1
p % p overrides x = 2
x = % prints 2
In order to avoid side effects, procedures that declare variables can enclose them in a dictionary local to the procedure.
/x 1 def
/p {
1 dict begin
/x 2 def
x = % 2
end
} def
x = % 1
p
x = % 1
It is quite common that we need to exit a procedure early depending on some condition. Or that we need an elsif
operator (which doesn't exist in PostScript) such as in the following Python code:
def compare(a, b):
if a < b:
return 1
elif a > b:
return -1
else: # a == b
return 0
Using the ifelse
PostScript operator will result in ugly cascading code blocks:
/_a_b_Compare_c_ {
/b exch def
/a exch def
a b gt {
1
} {
a b lt {
-1
} {
0
} ifelse
} ifelse
} def
We can implement the same behaviour with exit
statements inside a main loop:
/_a_b_Compare_c_ {
/b exch def
/a exch def
{
a b gt { 1 exit } if
a b lt { -1 exit } if
0 exit
} loop
} def
Here is an example of the assertions I use:
/AssertTypeBool {
1 dict begin
/x exch def
x type true type ne { (type error, expect boolean, got) x type = quit } if
end
} bind def
Here is an example of unit test construct I use.
The idea is to have a procedure taking the test name and one or two code blocks.
For instance, to assert that two values are equal, we can use:
/_s_p1_p2_AssertEqual {
/p2 exch def
/p1 exch def
/s exch def
p1 exec p2 exec eq { (--) =only s =only (: PASS) = }{ s =only (: FAIL) = p1 =only ( ne ) =only p2 = quit } ifelse
} def
The test will look like:
(TEST TestBoard.1) { testBoard (h1) _board_coord_Piece_p_ } { (R) } _s_p1_p2_AssertEqual
If the test fails, the program will stop and typically display:
TEST TestBoard.1: FAIL
R ne K
GhostScript errors are rather cryptic. Most information are garbage. Only the first line really matters.
Error: /typecheck in --forall--
Operand stack: --nostringval-- --nostringval--
Execution stack: %interp_exit .runexec2 --nostringval-- --nostringval-- --nostringval-- 2 %stopped_push --nostringval-- --nostringval-- --nostringval-- false 1 %stopped_push 1944 1 3 %oparray_pop 1943 1 3 %oparray_pop 1928 1 3 %oparray_pop 1801 1 3 %oparray_pop --nostringval-- %errorexec_pop .runexec2 --nostringval-- --nostringval-- --nostringval-- 2 %stopped_push --nostringval-- --nostringval-- %loop_continue --nostringval-- --nostringval-- %loop_continue --nostringval-- --nostringval-- --nostringval-- --nostringval-- --nostringval-- --nostringval--
Dictionary stack: --dict:749/1123(ro)(G)-- --dict:0/20(G)-- --dict:207/300(L)-- --dict:2/2(L)--
Current allocation mode is local
Current file position is 5285
GPL Ghostscript 10.02.1: Unrecoverable error, exit code 1
In this case, an Error: /typecheck
occurred where Current file position is 5285
. File position can checked in BBEdit with cmd-J :5285
.
Alas, I found the file position reported by GhostScript to be unreliable, stopping at first procedure call level, and hard to track when including files with the run
operator.
In order to know which procedure the error occured and how we got there, we can hook the typecheck
procedure.
/_tc { errordict/typecheck } bind def % save original procedure
errordict/typecheck { CS_PRINT _tc } put % redefine the procedure
Now we can add information when a typecheck
error occurs. See next section to understand how to add (rudimentary) stacktraces.
Conceptually, this technique consists in keeping a global stack, pushing and popping the names of the procedure as they are entered or exited.
We can implement the stack with an array and a top-of-stack index.
/CALLSTACK 20 array def
/CS_INDEX 0 def % callstack index
We add relevant procedures to put, pop and print.
/CS_PRINT {
5 dict begin
(\nCALLSTACK:) =
0 1 CSI {
/idx exch def
/line CALLSTACK idx get def
idx ==only ( ) =only line =
} for
end
} def
/CS_PUT {
1 dict begin
/s exch def
CALLSTACK CS_INDEX s put
end
/CSI CSI 1 add def
} def
/CS_POP {
CALLSTACK CS_INDEX null put
/CS_INDEX CS_INDEX 1 sub def
} def
Now, we can use CS_PUT
and CS_POP
as first and last calls in procedures. Eg:
/_p_a_AddToCaptured {
(/_p_a_AddToCaptured) CS_PUT
% ...
CS_POP
} def
This way, we can call CS_PRINT
in assertions, or in error hooks as suggested in the previous section.
/su_ { errordict/stackunderflow } bind def
errordict/stackunderflow { CS_PRINT su_ } put
A stackunderflow
error will then display valuable information:
CALLSTACK:
0 RunBoardLogicTests
1 TestCapture2
2 /_board_fc_tc_dry_Move_canMove_squaresStatus_msg_
3 /ChangePlayer_playerColor_
4 /_whiteOrBlack_Invert_s_
We don't need to hook all errors, mostly undefined
, rangecheck
, stackunderflow
and typecheck
. I suggest not to mess up too much with errordict
and enable (uncomment) these hooks only when investigating a specific error. Additionally, not all errors behave exactly the same way, and hooking them may require specific adjustments.
PostScript wasn't originally designed for animation purposes. However, I came up with a nice technique to create smooth animations using GhostView (gv
), a PostScript viewer.
The key technique is using a loop that where each iteration:
flushpage
sleep
procesude to controle the speed of the animationFor instance, here is how to display an animated bouncing ball:
Run with gv -quiet anim.ps
%!PS
%%BoundingBox: 0 0 320 240
/sleep { 100000 mul {} repeat } def
/x 50 def
0 1 300 1 sub {
pop
/x x 1 add def
gsave
1 setgray
0 0 320 240 rectfill
x 100 45 0 360 arc
0.5 setgray fill
grestore
flushpage
10 sleep
} for
References: