The first version of stroke draws only lines of one pixel width.

PostScript can however stroke lines of any width if you set it with the operator setlinewitdh. We add the linewidth to the context initialization and create the operators setlinewidth, currentlinewidth and also currentgray. The latter allow us to inspect the graphic state. Note there is no native PostScript operator currentpath, probably to copy protect the fonts which are also paths.

Run
rpn = function(s, context = null ) {
if (!context) context = {};
if (!context.stack) context.stack = [];
if (!context.dict) context.dict = {};
if (!context.heap) context.heap = [];
if (!context.lasterror) context.lasterror = "";
if (!context.currentcode) context.currentcode = "";
if (!context.width) context.width = 600;
if (!context.height) context.height = 800;
if (!context.data) context.data = new Uint8ClampedArray(context.width * context.height * 4);
if (!context.graphics) context.graphics = { path: [], current: [], color: [0, 0, 0, 255], linewidth: 1 };
if (!context.pop) context.pop = function (...parameters) {
if (this.stack.length < parameters.length) {
this.error("stackunderflow");
return [];
}
const result = [];
for (var p of parameters) {
const data = this.stack.pop();
if (p == "any") {
result.push(data);
} else if (p.includes(data.type)) {
result.push(data);
} else {
this.error("typeerror");
return [];
}
}
return result;
}
if (!context.popArray) context.popArray = function() {
var found = false;
var arr = [];
while (this.stack.length && ! found) {
const value = this.stack.pop();
if (value.type == "mark") {
found = true;
} else {
arr.push(value);
}
}
if (! found) {
return this.error("stackunderflow");
}
arr.reverse();
const a = new rpnArray(arr, this);
a.reference.inc();
this.stack.push(a);
}
if (!context.error) context.error = function(s) {
this.lasterror = s;
this.stack.push(new rpnError(s));
return this;
}
const list = s.concat(" ").split("");
var state = "start";
var current = "";
var depth = 0;
for (elem of list) {
if (context.lasterror) {
return context;
}
context.currentcode += elem;
switch (state) {
case "start":
if (/[0-9-]/.test(elem)) {
state = "number";
current = elem;
} else if (/[a-zA-Z]/.test(elem)) {
state = "operator";
current = elem;
} else if (elem == "/") {
state = "namestart";
} else if (elem == "{") {
state = "procedure";
} else if (elem == "(") {
state = "string";
} else if (elem == "[") {
context.stack.push(new rpnMark()); //mark
} else if (elem == "]") {
context.popArray();
} else if (elem == "%") {
state = "comment";
} else if (elem.trim() === '') {
// ignore whitespace
} else {
context.error("syntaxerror");
}
break;
case "number":
if (/[0-9]/.test(elem)) {
current += elem;
} else if (elem == ".") {
state = "fraction";
current += elem;
} else if (/[a-zA-Z-]/.test(elem)) {
context.error("syntaxerror");
} else if (elem.trim() === "") {
context.stack.push(new rpnNumber(parseFloat(current)));
state = "start";
current = "";
} else if (elem == "]") {
context.stack.push(new rpnNumber(parseFloat(current)));
state = "start";
current = "";
context.popArray();
} else {
context.error("syntaxerror");
}
break;
case "fraction":
if (/[0-9]/.test(elem)) {
current += elem;
} else if (elem.trim() === "") {
context.stack.push(new rpnNumber(parseFloat(current)));
state = "start";
current = "";
} else if (elem == "]") {
context.stack.push(new rpnNumber(parseFloat(current)));
state = "start";
current = "";
context.popArray();
} else {
context.error("syntaxerror");
}
break;
case "operator":
if (/[a-zA-Z0-9]/.test(elem)) {
current += elem;
} else if (elem.trim() !== "" && elem != "]"){
context.error("syntaxerror");
} else {
const data = context.dict[current];
const op = operators[current];
if (data) {
if (data.type == "procedure") {
context = rpn(data.value, context);
} else if (data.type == "array"){
data.reference.inc();
context.stack.push(data);
} else if (data.type == "string"){
data.reference.inc();
context.stack.push(data);
} else {
context.stack.push(data);
}
} else if (op) {
context = op(context);
} else {
context.error("syntaxerror");
}
state = "start";
current = "";
if (elem == "]") {
context.popArray();
}
}
break;
case "namestart":
if (/[a-zA-Z]/.test(elem)) {
state = "name";
current = elem;
} else {
context.error("syntaxerror");
}
break;
case "name":
if (/[a-zA-Z0-9]/.test(elem)) {
current += elem;
} else if (elem.trim() === "") {
context.stack.push(new rpnName(current));
state = "start";
current = "";
} else if (elem == "]") {
context.stack.push(new rpnName(current));
state = "start";
current = "";
context.popArray();
} else {
context.error("syntaxerror");
}
break;
case "procedure":
if (elem == "}") {
if (depth) {
depth--;
current += elem;
} else {
context.stack.push(new rpnProcedure(current));
state = "start";
current = "";
}
} else if (elem == "{") {
depth++;
current += elem;
} else {
current += elem;
}
break;
case "string":
if (elem == ")") {
if (depth) {
depth--;
current += elem;
} else {
const s = new rpnString(current, context);
s.reference.inc();
context.stack.push(s);
state = "start";
current = "";
}
} else if (elem == "(") {
depth++;
current += elem;
} else {
current += elem;
}
break;
case "comment":
if (elem == endofline ) {
state = "start";
}
} // switch state
} // for
if (state !== "start") {
context.error("syntaxerror");
}
return context;
};
operators.setlinewidth = function(context) {
const [w] = context.pop("number");
if (!w) return context;
const wlimited = Math.max(w.value,0);
context.graphics.linewidth = wlimited ;
return context;
};
unitTest("0 setlinewidth","");
unitTest("1 setlinewidth","");
unitTest("-1 setlinewidth","");
unitTest("(ab) setlinewidth","!typeerror");
unitTest("setlinewidth","!stackunderflow");
operators.currentlinewidth = function(context) { context.stack.push(new rpnNumber(context.graphics.linewidth));
return context;
};
unitTest("0 setlinewidth currentlinewidth","0");
unitTest("1 setlinewidth currentlinewidth","1");
unitTest("-1 setlinewidth currentlinewidth","0");
unitTest("currentlinewidth","1");
operators.currentgray = function(context) {
const [r, g, b] = context.graphics.color;
const y = (0.30*r + 0.61*g +0.09*b) / 255.0;
context.stack.push(new rpnNumber(y));
return context;
};
unitTest("0 setgray currentgray","0");
unitTest("1 setgray currentgray","0.9999999999999999");
unitTest("-1 setgray currentgray","0");
unitTest("currentgray","0");

Now we make a first naive approach with an operator we call stroke2. New repeat horizontally for each pixel.

Run
operators.stroke2 = function(context) {
// flatten the path
const flatpath = [];
for (var subpath of context.graphics.path) {
for (var line of subpath) {
if (line[0] == "C") {
const lines = bezier(line,-1);
for (var elem of lines) {
flatpath.push(elem)
}
} else {
flatpath.push(line);
}
}
}
for (var line of flatpath) {
const [type, x0, y0, x1, y1] = line;
const dx = x1 - x0;
const dy = y1 - y0;
const steps = Math.max(Math.abs(dx), Math.abs(dy));
if (steps) {
const ex = (x1 - x0) / steps;
const ey = (y1 - y0) / steps;
var x = x0;
var y = y0;
for (var i = 0; i < steps; i++) {
for (var j = 0; j < context.graphics.linewidth; j++) {
offset = ((context.height - 1 - Math.round(y)) * context.width + Math.round(x) + j ) * 4;
for (var c = 0; c < 4; c++) {
context.data[offset + c] = context.graphics.color[c];
}
}
x += ex;
y += ey;
}
}
}
context.graphics.path = [];
context.graphics.current = [];
return context;
};

As you can understand, this will not work for lines that are not vertical.

Run
postScriptEditor(`10 setlinewidth
40 200 moveto
100 250 lineto
200 200 lineto
200 100 lineto
300 100 lineto
300 170 400 170 400 100 curveto 500 100 lineto stroke2
`)

We have to go down to the schoolbooks and do the geometry. The definition of PostScript is: "stroke paints all points whose perpendicular distance from the current path in user space is less than or equal to one-half the absolute value of num."

Instead of one line we have two parallel lines. We can close them and simulate the linewidth by filling the path.

First we need to add the atan operator to calculate the angle. atan returns the angle based on dy and dx and works also when dx is 0 and tan(a) would actually be infinity. Javascript knows this function also under the name Math.atan2(num,denum), so the operator just exposes the function and handles the scale (pi / 180).

Run
operators.round = function(context) {
const [r] = context.pop("number");
if (!r) return context;
context.stack.push(new rpnNumber(Math.round(r.value)));
return context;
};
unitTest("1.6 round","2");
unitTest("1.4 round","1");
unitTest("1 round","1");
unitTest("-1.4 round","-1");
unitTest("(a) round","!typeerror");
unitTest("round","!stackunderflow");
operators.atan = function(context) {
const [denum, num] = context.pop("number","number");
if (!num) return context;
context.stack.push(new rpnNumber(Math.atan2(num.value,denum.value) * 180 / 3.1415926536 ));
return context;
};
unitTest("100 0 atan round","90");
unitTest("0 100 atan round","0");
unitTest("0 -100 atan round","180");
unitTest("100 100 atan round","45");
unitTest("0 0 atan round","0");
unitTest("100 atan","100 !stackunderflow");
unitTest("(a) 100 atan","!typeerror");
unitTest("atan","!stackunderflow");

We can write this directly in PostScript. We will have to do this separately for each subpath. We create two paths on each side of the middle path. Then we join the end of the first path with the reverse of the second path and close the path.

Run
operators.stroke3 = function(context) {
// flatten the path
const flatpath = [];
var subflatpath = [];
var patha = [];
var pathb = [];
const w = context.graphics.linewidth / 2;
const ad = 3.1415926535 / 2;
if (!w) {
return context;
}
for (var subpath of context.graphics.path) {
for (var line of subpath) {
if (line[0] == "C") {
const lines = bezier(line,-1);
for (var elem of lines) {
subflatpath.push(elem)
}
} else {
subflatpath.push(line);
}
}
for (var line of subflatpath) {
const [type, x0, y0, x1, y1] = line;
const a = Math.atan2(y1 - y0, x1 - x0);
const x0a = x0 + Math.cos(a - ad) * w;
const y0a = y0 + Math.sin(a - ad) * w;
const x1a = x1 + Math.cos(a - ad) * w;
const y1a = y1 + Math.sin(a - ad) * w;
patha.push([type, x0a, y0a, x1a, y1a]);
const x0b = x0 + Math.cos(a + ad) * w;
const y0b = y0 + Math.sin(a + ad) * w;
const x1b = x1 + Math.cos(a + ad) * w;
const y1b = y1 + Math.sin(a + ad) * w;
pathb.push([type, x0b, y0b, x1b, y1b]);
}
subflatpath = [];
pathb.reverse();
for (var line of patha) {
flatpath.push(line);
}
const lasta = patha[patha.length-1];
const firstb = pathb[0];
flatpath.push([ "L",lasta[3], lasta[4], firstb[3], firstb[4] ]);
for (var line of pathb) {
flatpath.push(["L", line[3], line[4], line[1], line[2] ] );
}
const lastb = pathb[pathb.length-1];
const firsta = patha[0];
flatpath.push([ "L",lastb[1], lastb[2], firsta[1], firsta[2] ]);
patha = [];
pats = [];
}
context.data = scanfill(flatpath, context.width, context.height, context.graphics.color, context.data)
context.graphics.path = [];
context.graphics.current = [];
return context;
};

Which works partially. The width is there in all directions, but we have problems went the lines join.

Run
postScriptEditor(`10 setlinewidth
40 200 moveto
100 250 lineto
200 200 lineto
200 100 lineto
300 100 lineto
300 170 400 170 400 100 curveto 500 100 lineto stroke3
`)

The problems are the crossing paths. When paths cross, the scanfill algorithm will not work properly. We to join the path segments on each side properly.

We calculate the connection points of the two lines, having 2 points for each line. It is the formula in Line-line intersection

So we rewrite

Run
lineIntersection = function(x1, y1, x2, y2, x3, y3, x4, y4) {
// https://en.wikipedia.org/wiki/Line–line_intersection
const denom = (x1 - x2) * (y3 - y4) - (y1 - y2) * (x3 - x4);
const nom1 = x1 * y2 - y1 * x2;
const nom2 = x3 * y4 - y3 * x4;
const x = (nom1 * (x3 - x4) - nom2 * (x1 - x2)) / denom;
const y = (nom1 * (y3 - y4) - nom2 * (y1 - y2)) / denom;
return [x, y];
}
operators.stroke4 = function(context) {
// flatten the path
const flatpath = [];
var subflatpath = [];
var patha = [];
var pathb = [];
const w = context.graphics.linewidth / 2;
const ad = 3.1415926535 / 2;
if (!w) {
return context;
}
for (var subpath of context.graphics.path) {
for (var line of subpath) {
if (line[0] == "C") {
const lines = bezier(line,-1);
for (var elem of lines) {
subflatpath.push(elem)
}
} else {
subflatpath.push(line);
}
}
for (var line of subflatpath) {
const [type, x0, y0, x1, y1] = line;
const a = Math.atan2(y1 - y0, x1 - x0);
const x0a = x0 + Math.cos(a - ad) * w;
const y0a = y0 + Math.sin(a - ad) * w;
const x1a = x1 + Math.cos(a - ad) * w;
const y1a = y1 + Math.sin(a - ad) * w;
patha.push([type, x0a, y0a, x1a, y1a]);
const x0b = x0 + Math.cos(a + ad) * w;
const y0b = y0 + Math.sin(a + ad) * w;
const x1b = x1 + Math.cos(a + ad) * w;
const y1b = y1 + Math.sin(a + ad) * w;
pathb.push([type, x0b, y0b, x1b, y1b]);
}
subflatpath = [];
pathb.reverse();
for (var line of patha) {
flatpath.push(line);
}
const lasta = patha[patha.length-1];
const firstb = pathb[0];
flatpath.push([ "L",lasta[3], lasta[4], firstb[3], firstb[4] ]);
for (var line of pathb) {
flatpath.push(["L", line[3], line[4], line[1], line[2] ] );
}
const lastb = pathb[pathb.length-1];
const firsta = patha[0];
flatpath.push([ "L",lastb[1], lastb[2], firsta[1], firsta[2] ]);
for(var i = 1; i < flatpath.length; i++) {
const [xc, yc] = lineIntersection(flatpath[i-1][1],flatpath[i-1][2],flatpath[i-1][3],flatpath[i-1][4],flatpath[i][1],flatpath[i][2],flatpath[i][3],flatpath[i][4]);
flatpath[i-1][3] = xc;
flatpath[i-1][4] = yc;
flatpath[i][1] = xc;
flatpath[i][2] = yc;
}
patha = [];
pats = [];
}
context.data = scanfill(flatpath, context.width, context.height, context.graphics.color, context.data)
context.graphics.path = [];
context.graphics.current = [];
return context;
};

Run
postScriptEditor(`10 setlinewidth
40 200 moveto
100 250 lineto
200 200 lineto
200 100 lineto
300 100 lineto
300 170 400 170 400 100 curveto 500 100 lineto stroke4
`)

Congratulations, if you followed until here. The codebase has now 1416 lines and we have implemented 58 operators (out of more than 400 built in operators). ps20240726.js

My Journey to PostScript