seriot.ch

About | Projects | Trail

Programming in PostScript

March 2023

Why PostScript?

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.

  1. Mental Model
  2. Dev Environment
  3. User Input
  4. Logs
  5. Naming Conventions
  6. Strings
  7. Dictionaries and Scopes
  8. Control Flow and Early Returns
  9. Asserts
  10. Unit Tests
  11. Errors
  12. Stack Traces
  13. Profiling

1. Mental Model

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

2. Dev Environment

I couldn't find a decent PostScript IDE on macOS. Here are the options I've considered, from best to worst:

3. Logs

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.

4. User Input

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)

5. Naming Conventions

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

6. Strings

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

7. Dictionaries and Scopes

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

8. Control Flow and Early Returns

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

9. Asserts

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

10. Unit Tests

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

11. Errors

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.

12. Stack Traces

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.

13. Profiling

Very basic profiling capabilities can be built with the usertime operator.