Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
71 changes: 71 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
# Changelog

All notable changes to the postscript gem will be documented in this file.

## [Unreleased]

### Added — 2026-07-28

**0.1.0 — initial release.**

The postscript gem is extracted from postsvg 0.3.0 as a standalone,
pure-Ruby PostScript (PS) / EPS parser, typed domain model, and
serializer. Independently reusable for any tool that needs to read,
write, or transform PostScript source.

* `Postscript::Source::Lexer` — comment-aware state-machine lexer
(preserves `%` chars inside string literals, which the legacy
postsvg Tokenizer's `gsub`-based comment stripping corrupted).
* `Postscript::Source::AstBuilder` — tokens → `Model::Program` with
per-operator `consumes` / `produces` stack-arity tracking so
chained operators parse cleanly.
* `Postscript::Source::OperandStack` — permissive pop (returns
`Computed` sentinel on underflow) so procedure bodies parse
without runtime context.
* `Postscript::Model::*` — typed PS records: `Program`,
`Literals::*` (Number, Name, StringLiteral, HexLiteral,
ArrayLiteral, Procedure, Dictionary), `Operators::*` (120 PS
operators across 13 PLRM chapters).
* `Postscript::Serializer` — `Model::Program` → PS / EPS source
text with DSC-conformant header (BoundingBox, HiResBoundingBox,
LanguageLevel, EPSF-3.0 marker). Idempotent round-trip
(serialize(parse(serialize(parse(x)))) == serialize(parse(x))).
* `Postscript::Matrix`, `Postscript::Color`,
`Postscript::FormatNumber` — general-purpose value types
(2D affine transforms, RGB/Gray/CMYK value object, PS-safe
float formatting).
* `Postscript::Error` hierarchy — typed errors for every failure
mode (ParseError, LexError, SyntaxError, StackUnderflowError,
UndefinedOperatorError, RecursionLimitError, etc.) plus
control-flow signals (ExitSignal, QuitSignal).
* Top-level convenience API: `Postscript.parse`,
`Postscript.serialize`, `Postscript.tokenize`.
* `exe/postscript` CLI: `parse`, `serialize`, `tokenize`,
`version` subcommands.

### Architecture

Mirrors the `emf`/`emfsvg` split: a separate gem owns format
parsing/model/serializer; the consumer gem (postsvg) depends on it
and adds the rendering/conversion layer. This unlocks:

* Independent reuse (PS validators, formatters, future PS↔PDF).
* Single-responsibility per gem.
* Clean dependency direction (postsvg depends on postscript, never
the other way).

### Code quality

- Pure Ruby autoload (no `require_relative` in lib/).
- No `respond_to?` for type checks, no `instance_variable_set/get`.
- No `send` to private methods.
- `# frozen_string_literal: true` on every `.rb` file.
- 149 specs, 0 failures.
- Round-trip property spec covering 6 representative PS programs.

### Backwards compatibility

postsvg's BC aliases (`Postsvg::Source = Postscript::Source`,
`Postsvg::Matrix = Postscript::Matrix`, etc.) continue to expose
the PS-side constants under the `Postsvg::*` namespace for
existing user code.
24 changes: 24 additions & 0 deletions LICENSE
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
BSD 2-Clause License

Copyright (c) 2026, Ribose Inc.

Redistribution and use in source and binary forms, with or without
modification, are permitted provided that the following conditions are met:

1. Redistributions of source code must retain the above copyright notice, this
list of conditions and the following disclaimer.

2. Redistributions in binary form must reproduce the above copyright notice,
this list of conditions and the following disclaimer in the documentation
and/or other materials provided with the distribution.

THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
7 changes: 7 additions & 0 deletions exe/postscript
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
#!/usr/bin/env ruby
# frozen_string_literal: true

require "postscript"
require "postscript/cli"

Postscript::CLI.start(ARGV)
30 changes: 30 additions & 0 deletions lib/postscript.rb
Original file line number Diff line number Diff line change
Expand Up @@ -30,4 +30,34 @@ module Postscript

# PS source serializer
autoload :Serializer, "postscript/serializer"

# CLI (only loaded when explicitly required or via the +postscript+
# executable). Avoids pulling in +thor+ for users who only need
# the library API.
autoload :CLI, "postscript/cli"

class << self
# One-shot: PS source string -> +Model::Program+.
#
# Example:
# program = Postscript.parse(ps_source)
# program.body.each { |node| puts node.class }
def parse(source)
Source.parse(source)
end

# One-shot: +Model::Program+ -> PS source string.
#
# Example:
# ps = Postscript.serialize(program, eps: true)
def serialize(program, **opts)
Serializer.call(program, **opts)
end

# One-shot: PS source string -> array of +Model::Token+.
# Useful for debugging or building alternative parsers.
def tokenize(source)
Source::Lexer.tokenize(source)
end
end
end
83 changes: 83 additions & 0 deletions lib/postscript/cli.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
# frozen_string_literal: true

require "thor"

module Postscript
# CLI for the postscript gem. Available via the +postscript+
# executable. Provides parse/serialize/tokenize/version
# subcommands for ad-hoc PS source inspection without writing
# Ruby.
class CLI < Thor
package_name "postscript"

desc "parse INPUT", "Parse a PS/EPS file and dump the AST shape"
long_desc <<~DESC
Read INPUT, lex + parse to a Model::Program, and print one
line per body statement showing its class. Useful for
verifying that the parser sees the structure you expect.
DESC
def parse(input_path)
source = read_input(input_path)
program = Postscript.parse(source)
say "Program: #{program.body.length} statement(s)"
say "BoundingBox: #{program.header.bounding_box.inspect}" if program.header.bounding_box
program.body.each do |node|
say " #{node.class}"
end
rescue Postscript::Error => e
say "Parse error: #{e.message}", :red
exit 1
end

desc "serialize INPUT [OUTPUT]", "Round-trip a PS file through parse + serialize"
long_desc <<~DESC
Read INPUT, parse to a Model::Program, serialize back to PS
source. Writes to OUTPUT if given, otherwise stdout.
DESC
option :eps, type: :boolean, default: false, desc: "Emit EPSF-3.0 header"
def serialize(input_path, output_path = nil)
source = read_input(input_path)
program = Postscript.parse(source)
output = Postscript.serialize(program, eps: options[:eps])
if output_path
File.write(output_path, output)
say "Wrote #{output_path} (#{output.bytesize} bytes)", :green
else
puts output
end
rescue Postscript::Error => e
say "Error: #{e.message}", :red
exit 1
end

desc "tokenize INPUT", "Lex a PS file and dump the token stream"
long_desc <<~DESC
Read INPUT and dump one line per token showing type, value,
and source position. Useful for debugging lexer issues.
DESC
def tokenize(input_path)
source = read_input(input_path)
Postscript.tokenize(source).each do |token|
puts "%4d:%-4d %-12s %s" % [token.line, token.column, token.type, token.value]
end
rescue Postscript::Error => e
say "Lex error: #{e.message}", :red
exit 1
end

desc "version", "Show the postscript gem version"
def version
say "postscript #{Postscript::VERSION}"
end

private

def read_input(path)
unless File.exist?(path)
say "Error: input file '#{path}' not found", :red
exit 1
end
File.read(path)
end
end
end
22 changes: 11 additions & 11 deletions lib/postscript/model/operators/boolean.rb
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ class False < Operator
end

class Eq < Operator
register_as "eq"
register_as "eq", consumes: 2, produces: 1
def self.from_operands(stack)
b = stack.pop
a = stack.pop
Expand All @@ -32,7 +32,7 @@ def initialize(operand_a:, operand_b:)
end

class Ne < Operator
register_as "ne"
register_as "ne", consumes: 2, produces: 1
def self.from_operands(stack)
b = stack.pop
a = stack.pop
Expand All @@ -48,7 +48,7 @@ def initialize(operand_a:, operand_b:)
end

class Gt < Operator
register_as "gt"
register_as "gt", consumes: 2, produces: 1
def self.from_operands(stack)
b = stack.pop_number
a = stack.pop_number
Expand All @@ -64,7 +64,7 @@ def initialize(operand_a:, operand_b:)
end

class Ge < Operator
register_as "ge"
register_as "ge", consumes: 2, produces: 1
def self.from_operands(stack)
b = stack.pop_number
a = stack.pop_number
Expand All @@ -80,7 +80,7 @@ def initialize(operand_a:, operand_b:)
end

class Lt < Operator
register_as "lt"
register_as "lt", consumes: 2, produces: 1
def self.from_operands(stack)
b = stack.pop_number
a = stack.pop_number
Expand All @@ -96,7 +96,7 @@ def initialize(operand_a:, operand_b:)
end

class Le < Operator
register_as "le"
register_as "le", consumes: 2, produces: 1
def self.from_operands(stack)
b = stack.pop_number
a = stack.pop_number
Expand All @@ -112,7 +112,7 @@ def initialize(operand_a:, operand_b:)
end

class And < Operator
register_as "and"
register_as "and", consumes: 2, produces: 1
def self.from_operands(stack)
b = stack.pop
a = stack.pop
Expand All @@ -128,7 +128,7 @@ def initialize(operand_a:, operand_b:)
end

class Or < Operator
register_as "or"
register_as "or", consumes: 2, produces: 1
def self.from_operands(stack)
b = stack.pop
a = stack.pop
Expand All @@ -144,7 +144,7 @@ def initialize(operand_a:, operand_b:)
end

class Xor < Operator
register_as "xor"
register_as "xor", consumes: 2, produces: 1
def self.from_operands(stack)
b = stack.pop
a = stack.pop
Expand All @@ -160,7 +160,7 @@ def initialize(operand_a:, operand_b:)
end

class Not < Operator
register_as "not"
register_as "not", consumes: 1, produces: 1
attr_reader :operand

def initialize(operand:)
Expand All @@ -174,7 +174,7 @@ def self.from_operands(stack)
end

class Bitshift < Operator
register_as "bitshift"
register_as "bitshift", consumes: 2, produces: 1
def self.from_operands(stack)
shift = stack.pop_number.to_i
value = stack.pop_number.to_i
Expand Down
2 changes: 1 addition & 1 deletion lib/postscript/model/operators/transformations.rb
Original file line number Diff line number Diff line change
Expand Up @@ -67,7 +67,7 @@ def self.from_operands(stack)
end

class Matrix < Operator
register_as "matrix"
register_as "matrix", consumes: 0, produces: 1
end

class Currentmatrix < Operator
Expand Down
Loading