March 2023
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)
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.
Very basic profiling capabilities can be built with the usertime
operator.