diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..2a226f7 --- /dev/null +++ b/CHANGELOG.md @@ -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. diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..7d71bec --- /dev/null +++ b/LICENSE @@ -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. diff --git a/exe/postscript b/exe/postscript new file mode 100755 index 0000000..af2da72 --- /dev/null +++ b/exe/postscript @@ -0,0 +1,7 @@ +#!/usr/bin/env ruby +# frozen_string_literal: true + +require "postscript" +require "postscript/cli" + +Postscript::CLI.start(ARGV) diff --git a/lib/postscript.rb b/lib/postscript.rb index 95bb4a7..4060bc2 100644 --- a/lib/postscript.rb +++ b/lib/postscript.rb @@ -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 diff --git a/lib/postscript/cli.rb b/lib/postscript/cli.rb new file mode 100644 index 0000000..3554107 --- /dev/null +++ b/lib/postscript/cli.rb @@ -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 diff --git a/lib/postscript/model/operators/boolean.rb b/lib/postscript/model/operators/boolean.rb index 806f853..fbec89c 100644 --- a/lib/postscript/model/operators/boolean.rb +++ b/lib/postscript/model/operators/boolean.rb @@ -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 @@ -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 @@ -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 @@ -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 @@ -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 @@ -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 @@ -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 @@ -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 @@ -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 @@ -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:) @@ -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 diff --git a/lib/postscript/model/operators/transformations.rb b/lib/postscript/model/operators/transformations.rb index e3ac93f..d008fb1 100644 --- a/lib/postscript/model/operators/transformations.rb +++ b/lib/postscript/model/operators/transformations.rb @@ -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 diff --git a/lib/postscript/serializer.rb b/lib/postscript/serializer.rb index 248f4fc..3e9434e 100644 --- a/lib/postscript/serializer.rb +++ b/lib/postscript/serializer.rb @@ -33,11 +33,17 @@ def call buffer = String.new(capacity: 4096) emit_header(buffer) emit_body(buffer) - buffer << "showpage\n" + buffer << "showpage\n" unless body_has_showpage? buffer << "%%EOF\n" buffer end + # Returns true if the program body already contains a +showpage+ + # operator, so the serializer doesn't append a duplicate. + def body_has_showpage? + program.body.any? { |s| s.is_a?(Model::Operators::Device::Showpage) } + end + # All methods below this point are public. They are intentionally # exposed (prefixed +emit_+) so dispatch can reach them via # +method_defined?+ without +respond_to?+. @@ -52,6 +58,12 @@ def emit_header(buffer) FormatNumber.call(v) }.join(" ") << "\n" end + hires = program.header.hires_bounding_box + if hires && !hires.empty? + buffer << "%%HiResBoundingBox: " << hires.map { |v| + FormatNumber.call(v) + }.join(" ") << "\n" + end buffer << "%%Title: #{program.header.title}\n" if program.header.title buffer << "%%LanguageLevel: #{DEFAULT_LANGUAGE_LEVEL}\n" buffer << "%%EndComments\n" @@ -112,148 +124,15 @@ def emit_inline(statement, buffer) end end - # Dispatch table: emit_. Falls back to the generic - # operand+keyword form when no specific method exists. + # Dispatch: just emit the keyword. Operands are already in the + # program body as separate literal statements (added by the + # AstBuilder). This keeps round-trip idempotent: serialized + # output re-parses to an identical AST. def emit_operator(operator, buffer) - method = :"emit_#{operator.visit_name}" - if self.class.public_method_defined?(method) - public_send(method, operator, buffer) - else - emit_generic_operator(operator, buffer) - end - end - - def emit_generic_operator(operator, buffer) buffer << operator.keyword << "\n" end # Path - def emit_moveto(op, buf) - buf << "#{FormatNumber.call(op.x)} #{FormatNumber.call(op.y)} moveto\n" - end - - def emit_rmoveto(op, buf) - buf << "#{FormatNumber.call(op.dx)} #{FormatNumber.call(op.dy)} rmoveto\n" - end - - def emit_lineto(op, buf) - buf << "#{FormatNumber.call(op.x)} #{FormatNumber.call(op.y)} lineto\n" - end - - def emit_rlineto(op, buf) - buf << "#{FormatNumber.call(op.dx)} #{FormatNumber.call(op.dy)} rlineto\n" - end - - def emit_curveto(op, buf) - buf << "#{FormatNumber.call(op.x1)} #{FormatNumber.call(op.y1)} " \ - "#{FormatNumber.call(op.x2)} #{FormatNumber.call(op.y2)} " \ - "#{FormatNumber.call(op.x3)} #{FormatNumber.call(op.y3)} curveto\n" - end - - def emit_rcurveto(op, buf) - buf << "#{FormatNumber.call(op.dx1)} #{FormatNumber.call(op.dy1)} " \ - "#{FormatNumber.call(op.dx2)} #{FormatNumber.call(op.dy2)} " \ - "#{FormatNumber.call(op.dx3)} #{FormatNumber.call(op.dy3)} rcurveto\n" - end - - def emit_arc(op, buf) - buf << "#{FormatNumber.call(op.x)} #{FormatNumber.call(op.y)} " \ - "#{FormatNumber.call(op.radius)} " \ - "#{FormatNumber.call(op.angle1)} #{FormatNumber.call(op.angle2)} arc\n" - end - - def emit_arcn(op, buf) - buf << "#{FormatNumber.call(op.x)} #{FormatNumber.call(op.y)} " \ - "#{FormatNumber.call(op.radius)} " \ - "#{FormatNumber.call(op.angle1)} #{FormatNumber.call(op.angle2)} arcn\n" - end - - def emit_closepath(_op, buf) - buf << "closepath\n" - end - - def emit_newpath(_op, buf) - buf << "newpath\n" - end - - # Painting - def emit_stroke(_op, buf) = buf << "stroke\n" - def emit_fill(_op, buf) = buf << "fill\n" - def emit_eofill(_op, buf) = buf << "eofill\n" - def emit_clip(_op, buf) = buf << "clip\n" - def emit_eoclip(_op, buf) = buf << "eoclip\n" - - # Color - def emit_setrgbcolor(op, buf) - buf << "#{FormatNumber.call(op.red)} #{FormatNumber.call(op.green)} #{FormatNumber.call(op.blue)} setrgbcolor\n" - end - - def emit_setgray(op, buf) - buf << "#{FormatNumber.call(op.gray)} setgray\n" - end - - def emit_setcmykcolor(op, buf) - buf << "#{FormatNumber.call(op.cyan)} #{FormatNumber.call(op.magenta)} " \ - "#{FormatNumber.call(op.yellow)} #{FormatNumber.call(op.key)} setcmykcolor\n" - end - - def emit_sethsbcolor(op, buf) - buf << "#{FormatNumber.call(op.hue)} #{FormatNumber.call(op.saturation)} " \ - "#{FormatNumber.call(op.brightness)} sethsbcolor\n" - end - - # Graphics state - def emit_gsave(_op, buf) = buf << "gsave\n" - def emit_grestore(_op, buf) = buf << "grestore\n" - def emit_grestoreall(_op, buf) = buf << "grestoreall\n" - - def emit_setlinewidth(op, buf) - buf << "#{FormatNumber.call(op.width)} setlinewidth\n" - end - - def emit_setlinecap(op, buf) - buf << "#{op.cap_code} setlinecap\n" - end - - def emit_setlinejoin(op, buf) - buf << "#{op.join_code} setlinejoin\n" - end - - def emit_setmiterlimit(op, buf) - buf << "#{FormatNumber.call(op.limit)} setmiterlimit\n" - end - - def emit_setdash(op, buf) - pattern_str = - case op.pattern - when Array then "[#{op.pattern.map do |v| - FormatNumber.call(v.to_f) - end.join(' ')}]" - when Numeric then FormatNumber.call(op.pattern.to_f) - else "[]" - end - buf << "#{pattern_str} #{FormatNumber.call(op.offset)} setdash\n" - end - - # Transformations - def emit_translate(op, buf) - buf << "#{FormatNumber.call(op.tx)} #{FormatNumber.call(op.ty)} translate\n" - end - - def emit_scale(op, buf) - buf << "#{FormatNumber.call(op.sx)} #{FormatNumber.call(op.sy)} scale\n" - end - - def emit_rotate(op, buf) - buf << "#{FormatNumber.call(op.angle)} rotate\n" - end - - def emit_concat(op, buf) - m = op.matrix - arr = m.is_a?(Array) ? m : [m.a, m.b, m.c, m.d, m.e, m.f] - buf << "[#{arr.map { |v| FormatNumber.call(v.to_f) }.join(' ')}] concat\n" - end - def escape_string(text) escaped = text.to_s.gsub(/[()\\]/) { |c| "\\#{c}" } .gsub("\n", '\\n') @@ -262,72 +141,5 @@ def escape_string(text) "(#{escaped})" end - # Font / text - def emit_findfont(op, buf) - name = font_name_value(op.name) - buf << "/#{name} findfont\n" - end - - def emit_scalefont(op, buf) - buf << "#{FormatNumber.call(op.size)} scalefont\n" - end - - def emit_setfont(_op, buf) - buf << "setfont\n" - end - - def emit_show(op, buf) - buf << "#{escape_string(string_value(op.text))} show\n" - end - - def emit_xyshow(_op, buf) - buf << "% xyshow: per-glyph advances not serialized\n" - end - - def emit_stringwidth(_op, buf) - buf << "% stringwidth: no runtime result captured\n" - end - - def emit_charpath(_op, buf) - buf << "% charpath: requires font metrics\n" - end - - # Container operators - def emit_length(_op, buf) = buf << "length\n" - def emit_get(_op, buf) = buf << "get\n" - def emit_put(_op, buf) = buf << "put\n" - def emit_getinterval(_op, buf) = buf << "getinterval\n" - def emit_putinterval(_op, buf) = buf << "putinterval\n" - def emit_forall(_op, buf) = buf << "forall\n" - def emit_astore(_op, buf) = buf << "astore\n" - def emit_search(_op, buf) = buf << "search\n" - def emit_anchorsearch(_op, buf) = buf << "anchorsearch\n" - def emit_token(_op, buf) = buf << "token\n" - def emit_string(_op, buf) = buf << "string\n" - def emit_cvs(_op, buf) = buf << "cvs\n" - - # Dictionary operators (currentdict etc.) - def emit_currentdict(_op, buf) = buf << "currentdict\n" - def emit_countdictstack(_op, buf) = buf << "countdictstack\n" - def emit_dictstack(_op, buf) = buf << "dictstack\n" - def emit_maxlength(_op, buf) = buf << "maxlength\n" - - def font_name_value(value) - case value - when Model::Literals::Name then value.value - when Model::Literals::StringLiteral then value.value - when String then value - else value.to_s - end - end - - def string_value(value) - case value - when Model::Literals::StringLiteral then value.value - when Model::Literals::HexLiteral then value.bytes - when String then value - else value.to_s - end - end end end diff --git a/postscript.gemspec b/postscript.gemspec index a9411d0..41185bd 100644 --- a/postscript.gemspec +++ b/postscript.gemspec @@ -34,4 +34,6 @@ Gem::Specification.new do |spec| spec.bindir = "exe" spec.executables = spec.files.grep(%r{\Aexe/}) { |f| File.basename(f) } spec.require_paths = ["lib"] + + spec.add_dependency "thor" end diff --git a/spec/postscript/model/operators/arithmetic_spec.rb b/spec/postscript/model/operators/arithmetic_spec.rb new file mode 100644 index 0000000..b1955fc --- /dev/null +++ b/spec/postscript/model/operators/arithmetic_spec.rb @@ -0,0 +1,91 @@ +# frozen_string_literal: true + +require "spec_helper" + +RSpec.describe Postscript::Model::Operators::Arithmetic do + before(:all) { Postscript::Model::Operators.load_all! } + + let(:stack) { Postscript::Source::OperandStack.new } + + def binary_op(klass, a, b) + stack.push(a) + stack.push(b) + klass.from_operands(stack) + end + + def unary_op(klass, value) + stack.push(value) + klass.from_operands(stack) + end + + describe "::Add" do + it "pops operand_b first, then operand_a" do + op = binary_op(described_class::Add, 2, 3) + expect(op.operand_a).to eq(2) + expect(op.operand_b).to eq(3) + end + + it "declares consumes:2, produces:1" do + expect(described_class::Add.consumes).to eq(2) + expect(described_class::Add.produces).to eq(1) + end + end + + describe "::Sub" do + it "preserves a - b ordering (a is operand_a)" do + op = binary_op(described_class::Sub, 10, 3) + expect(op.operand_a).to eq(10) + expect(op.operand_b).to eq(3) + end + end + + describe "::Div" do + it "is real-valued (not integer)" do + op = binary_op(described_class::Div, 10, 4) + expect(op.operand_a).to eq(10) + expect(op.operand_b).to eq(4) + end + end + + describe "::Idiv" do + it "coerces operands to integers" do + op = binary_op(described_class::Idiv, 10.0, 3.0) + expect(op.operand_a).to eq(10) + expect(op.operand_b).to eq(3) + end + end + + describe "::Mod" do + it "coerces operands to integers" do + op = binary_op(described_class::Mod, 10.5, 3.5) + expect(op.operand_a).to eq(10) + expect(op.operand_b).to eq(3) + end + end + + %i[Neg Abs Ceiling Floor Round Truncate Sqrt Cos Sin Ln Log].each do |unary| + describe "::#{unary}" do + it "is unary (consumes:1, produces:1)" do + klass = described_class.const_get(unary) + expect(klass.consumes).to eq(1) + expect(klass.produces).to eq(1) + end + end + end + + describe "::Atan" do + it "is binary (atan2 semantics)" do + op = binary_op(described_class::Atan, 1, 1) + expect(op.operand_a).to eq(1) + expect(op.operand_b).to eq(1) + end + end + + describe "::Exp" do + it "computes a ** b" do + op = binary_op(described_class::Exp, 2, 8) + expect(op.operand_a).to eq(2) + expect(op.operand_b).to eq(8) + end + end +end diff --git a/spec/postscript/model/operators/boolean_spec.rb b/spec/postscript/model/operators/boolean_spec.rb new file mode 100644 index 0000000..52363ae --- /dev/null +++ b/spec/postscript/model/operators/boolean_spec.rb @@ -0,0 +1,62 @@ +# frozen_string_literal: true + +require "spec_helper" + +RSpec.describe Postscript::Model::Operators::Boolean do + before(:all) { Postscript::Model::Operators.load_all! } + + let(:stack) { Postscript::Source::OperandStack.new } + + describe "::True" do + it "is a zero-arity constant operator that produces 1" do + expect(described_class::True.consumes).to eq(0) + expect(described_class::True.produces).to eq(1) + end + end + + describe "::False" do + it "is a zero-arity constant operator that produces 1" do + expect(described_class::False.consumes).to eq(0) + expect(described_class::False.produces).to eq(1) + end + end + + %i[Eq Ne Gt Ge Lt Le].each do |cmp| + describe "::#{cmp}" do + it "is binary comparison" do + klass = described_class.const_get(cmp) + expect(klass.consumes).to eq(2) + expect(klass.produces).to eq(1) + end + end + end + + describe "::And" do + it "pops two operands" do + stack.push(true) + stack.push(false) + op = described_class::And.from_operands(stack) + expect(op.operand_a).to eq(true) + expect(op.operand_b).to eq(false) + end + end + + describe "::Not" do + it "is unary" do + stack.push(true) + op = described_class::Not.from_operands(stack) + expect(op.operand).to eq(true) + expect(described_class::Not.consumes).to eq(1) + end + end + + describe "::Bitshift" do + it "pops shift then value (value is bottom)" do + stack.push(8) # value + stack.push(2) # shift (top) + op = described_class::Bitshift.from_operands(stack) + expect(op.operand).to eq(8) + expect(op.shift).to eq(2) + end + end +end diff --git a/spec/postscript/model/operators/color_spec.rb b/spec/postscript/model/operators/color_spec.rb new file mode 100644 index 0000000..fdff39b --- /dev/null +++ b/spec/postscript/model/operators/color_spec.rb @@ -0,0 +1,50 @@ +# frozen_string_literal: true + +require "spec_helper" + +RSpec.describe Postscript::Model::Operators::Color do + before(:all) { Postscript::Model::Operators.load_all! } + + let(:stack) { Postscript::Source::OperandStack.new } + + describe "::Setgray" do + it "pops the gray level" do + stack.push(0.5) + op = described_class::Setgray.from_operands(stack) + expect(op.gray).to eq(0.5) + end + end + + describe "::Setrgbcolor" do + it "pops blue, green, red in that order" do + stack.push(0.1) # r + stack.push(0.2) # g + stack.push(0.3) # b (top) + op = described_class::Setrgbcolor.from_operands(stack) + expect(op.red).to eq(0.1) + expect(op.green).to eq(0.2) + expect(op.blue).to eq(0.3) + end + end + + describe "::Setcmykcolor" do + it "pops key, yellow, magenta, cyan in that order" do + stack.push(0.1); stack.push(0.2); stack.push(0.3); stack.push(0.4) + op = described_class::Setcmykcolor.from_operands(stack) + expect(op.cyan).to eq(0.1) + expect(op.magenta).to eq(0.2) + expect(op.yellow).to eq(0.3) + expect(op.key).to eq(0.4) + end + end + + describe "::Sethsbcolor" do + it "pops brightness, saturation, hue in that order" do + stack.push(0.1); stack.push(0.2); stack.push(0.3) + op = described_class::Sethsbcolor.from_operands(stack) + expect(op.hue).to eq(0.1) + expect(op.saturation).to eq(0.2) + expect(op.brightness).to eq(0.3) + end + end +end diff --git a/spec/postscript/model/operators/container_spec.rb b/spec/postscript/model/operators/container_spec.rb new file mode 100644 index 0000000..b3a2fbe --- /dev/null +++ b/spec/postscript/model/operators/container_spec.rb @@ -0,0 +1,57 @@ +# frozen_string_literal: true + +require "spec_helper" + +RSpec.describe Postscript::Model::Operators::Container do + before(:all) { Postscript::Model::Operators.load_all! } + + let(:stack) { Postscript::Source::OperandStack.new } + + describe "::Length" do + it "pops the collection, produces 1" do + stack.push([1, 2, 3]) + described_class::Length.from_operands(stack) + expect(stack.empty?).to be true + expect(described_class::Length.produces).to eq(1) + end + end + + describe "::Get" do + it "pops key then collection" do + stack.push({ "a" => 1 }) + stack.push("a") + op = described_class::Get.from_operands(stack) + expect(op.key).to eq("a") + end + end + + describe "::Put" do + it "pops value, key, collection" do + stack.push({}) + stack.push("k") + stack.push(99) + op = described_class::Put.from_operands(stack) + expect(op.key).to eq("k") + expect(op.value).to eq(99) + end + end + + describe "::Search" do + it "pops pattern then target" do + stack.push("hello world") + stack.push("world") + op = described_class::Search.from_operands(stack) + expect(op.target).to eq("hello world") + expect(op.pattern).to eq("world") + end + end + + describe "::String" do + it "pops count and produces a buffer" do + stack.push(10) + op = described_class::String.from_operands(stack) + expect(op.count).to eq(10) + expect(described_class::String.produces).to eq(1) + end + end +end diff --git a/spec/postscript/model/operators/control_flow_spec.rb b/spec/postscript/model/operators/control_flow_spec.rb new file mode 100644 index 0000000..0f34658 --- /dev/null +++ b/spec/postscript/model/operators/control_flow_spec.rb @@ -0,0 +1,66 @@ +# frozen_string_literal: true + +require "spec_helper" + +RSpec.describe Postscript::Model::Operators::ControlFlow do + before(:all) { Postscript::Model::Operators.load_all! } + + let(:stack) { Postscript::Source::OperandStack.new } + + describe "::If" do + it "pops body then condition" do + body = Postscript::Model::Literals::Procedure.new([]) + stack.push(true) + stack.push(body) + op = described_class::If.from_operands(stack) + expect(op.condition).to be true + expect(op.body).to be(body) + end + end + + describe "::Ifelse" do + it "pops else_body, if_body, condition" do + if_body = Postscript::Model::Literals::Procedure.new([]) + else_body = Postscript::Model::Literals::Procedure.new([]) + stack.push(false) + stack.push(if_body) + stack.push(else_body) + op = described_class::Ifelse.from_operands(stack) + expect(op.condition).to be false + expect(op.if_body).to be(if_body) + expect(op.else_body).to be(else_body) + end + end + + describe "::Repeat" do + it "pops body then count" do + body = Postscript::Model::Literals::Procedure.new([]) + stack.push(5) + stack.push(body) + op = described_class::Repeat.from_operands(stack) + expect(op.count).to eq(5) + expect(op.body).to be(body) + end + end + + describe "::For" do + it "pops body, limit, increment, initial" do + body = Postscript::Model::Literals::Procedure.new([]) + stack.push(0) # initial + stack.push(1) # increment + stack.push(10) # limit + stack.push(body) # body (top) + op = described_class::For.from_operands(stack) + expect(op.initial).to eq(0) + expect(op.increment).to eq(1) + expect(op.limit).to eq(10) + end + end + + describe "::Exit / ::Quit" do + it "are zero-arity" do + expect(described_class::Exit.consumes).to eq(0) + expect(described_class::Quit.consumes).to eq(0) + end + end +end diff --git a/spec/postscript/model/operators/device_spec.rb b/spec/postscript/model/operators/device_spec.rb new file mode 100644 index 0000000..0ae7387 --- /dev/null +++ b/spec/postscript/model/operators/device_spec.rb @@ -0,0 +1,17 @@ +# frozen_string_literal: true + +require "spec_helper" + +RSpec.describe Postscript::Model::Operators::Device do + before(:all) { Postscript::Model::Operators.load_all! } + + %i[Showpage Copypage Nulldevice].each do |op| + describe "::#{op}" do + it "is zero-arity" do + klass = described_class.const_get(op) + expect(klass.consumes).to eq(0) + expect(klass.produces).to eq(0) + end + end + end +end diff --git a/spec/postscript/model/operators/dictionary_spec.rb b/spec/postscript/model/operators/dictionary_spec.rb new file mode 100644 index 0000000..7c933cf --- /dev/null +++ b/spec/postscript/model/operators/dictionary_spec.rb @@ -0,0 +1,59 @@ +# frozen_string_literal: true + +require "spec_helper" + +RSpec.describe Postscript::Model::Operators::Dictionary do + before(:all) { Postscript::Model::Operators.load_all! } + + let(:stack) { Postscript::Source::OperandStack.new } + + describe "::Dict" do + it "pops 1 (size hint), produces 1 (the dict)" do + stack.push(4) + described_class::Dict.from_operands(stack) + expect(stack.empty?).to be true + expect(described_class::Dict.consumes).to eq(1) + expect(described_class::Dict.produces).to eq(1) + end + end + + describe "::Begin" do + it "pops 1 (the dict)" do + stack.push({}) + described_class::Begin.from_operands(stack) + expect(stack.empty?).to be true + end + end + + describe "::End" do + it "is zero-arity" do + expect(described_class::End.consumes).to eq(0) + end + end + + describe "::Def" do + it "pops value then key" do + stack.push(Postscript::Model::Literals::Name.new("foo", literal: true)) + stack.push(42) + op = described_class::Def.from_operands(stack) + expect(op.key).to be_a(Postscript::Model::Literals::Name) + expect(op.value).to eq(42) + end + end + + describe "::Load" do + it "pops one key, produces 1" do + stack.push("foo") + op = described_class::Load.from_operands(stack) + expect(op.key).to eq("foo") + expect(described_class::Load.produces).to eq(1) + end + end + + describe "::Currentdict / ::Countdictstack" do + it "produce 1 each" do + expect(described_class::Currentdict.produces).to eq(1) + expect(described_class::Countdictstack.produces).to eq(1) + end + end +end diff --git a/spec/postscript/model/operators/font_spec.rb b/spec/postscript/model/operators/font_spec.rb new file mode 100644 index 0000000..67a0bec --- /dev/null +++ b/spec/postscript/model/operators/font_spec.rb @@ -0,0 +1,44 @@ +# frozen_string_literal: true + +require "spec_helper" + +RSpec.describe Postscript::Model::Operators::Font do + before(:all) { Postscript::Model::Operators.load_all! } + + let(:stack) { Postscript::Source::OperandStack.new } + + describe "::Findfont" do + it "pops the font name, produces 1" do + stack.push("Helvetica") + op = described_class::Findfont.from_operands(stack) + expect(op.name).to eq("Helvetica") + expect(described_class::Findfont.produces).to eq(1) + end + end + + describe "::Scalefont" do + it "pops size then font" do + stack.push("font_ref") + stack.push(14) + op = described_class::Scalefont.from_operands(stack) + expect(op.size).to eq(14) + expect(op.font).to eq("font_ref") + end + end + + describe "::Setfont" do + it "pops the font ref" do + stack.push("font_ref") + op = described_class::Setfont.from_operands(stack) + expect(op.font).to eq("font_ref") + end + end + + describe "::Show" do + it "pops the text" do + stack.push("Hello") + op = described_class::Show.from_operands(stack) + expect(op.text).to eq("Hello") + end + end +end diff --git a/spec/postscript/model/operators/graphics_state_spec.rb b/spec/postscript/model/operators/graphics_state_spec.rb new file mode 100644 index 0000000..0baa5c2 --- /dev/null +++ b/spec/postscript/model/operators/graphics_state_spec.rb @@ -0,0 +1,51 @@ +# frozen_string_literal: true + +require "spec_helper" + +RSpec.describe Postscript::Model::Operators::GraphicsState do + before(:all) { Postscript::Model::Operators.load_all! } + + let(:stack) { Postscript::Source::OperandStack.new } + + describe "::Gsave / ::Grestore / ::Grestoreall" do + it "are zero-arity" do + expect(described_class::Gsave.consumes).to eq(0) + expect(described_class::Grestore.consumes).to eq(0) + expect(described_class::Grestoreall.consumes).to eq(0) + end + end + + describe "::Setlinewidth" do + it "pops one number" do + stack.push(2.5) + op = described_class::Setlinewidth.from_operands(stack) + expect(op.width).to eq(2.5) + end + end + + describe "::Setlinecap" do + it "coerces to integer cap code" do + stack.push(1.0) + op = described_class::Setlinecap.from_operands(stack) + expect(op.cap_code).to eq(1) + end + end + + describe "::Setlinejoin" do + it "coerces to integer join code" do + stack.push(2) + op = described_class::Setlinejoin.from_operands(stack) + expect(op.join_code).to eq(2) + end + end + + describe "::Setdash" do + it "pops offset then pattern (an array)" do + stack.push([5.0, 3.0]) + stack.push(1.0) + op = described_class::Setdash.from_operands(stack) + expect(op.pattern).to eq([5.0, 3.0]) + expect(op.offset).to eq(1.0) + end + end +end diff --git a/spec/postscript/model/operators/painting_spec.rb b/spec/postscript/model/operators/painting_spec.rb new file mode 100644 index 0000000..5672fe3 --- /dev/null +++ b/spec/postscript/model/operators/painting_spec.rb @@ -0,0 +1,22 @@ +# frozen_string_literal: true + +require "spec_helper" + +RSpec.describe Postscript::Model::Operators::Painting do + before(:all) { Postscript::Model::Operators.load_all! } + + %i[Stroke Fill Eofill Clip Eoclip].each do |op| + describe "::#{op}" do + it "is zero-arity (consumes nothing, produces nothing)" do + klass = described_class.const_get(op) + expect(klass.consumes).to eq(0) + expect(klass.produces).to eq(0) + end + + it "registers under the lowercase keyword" do + klass = described_class.const_get(op) + expect(Postscript::Model::Operators[op.to_s.downcase]).to be(klass) + end + end + end +end diff --git a/spec/postscript/model/operators/path_spec.rb b/spec/postscript/model/operators/path_spec.rb new file mode 100644 index 0000000..b40988a --- /dev/null +++ b/spec/postscript/model/operators/path_spec.rb @@ -0,0 +1,77 @@ +# frozen_string_literal: true + +require "spec_helper" + +RSpec.describe Postscript::Model::Operators::Path do + before(:all) { Postscript::Model::Operators.load_all! } + + let(:stack) { Postscript::Source::OperandStack.new } + + describe "::Moveto" do + it "pops y first then x" do + stack.push(10) # x + stack.push(20) # y (top) + op = described_class::Moveto.from_operands(stack) + expect(op.x).to eq(10) + expect(op.y).to eq(20) + end + end + + describe "::Lineto" do + it "has same shape as Moveto" do + stack.push(5) + stack.push(7) + op = described_class::Lineto.from_operands(stack) + expect(op.x).to eq(5) + expect(op.y).to eq(7) + end + end + + describe "::Rmoveto / ::Rlineto" do + it "exposes dx/dy" do + stack.push(3); stack.push(4) + op = described_class::Rmoveto.from_operands(stack) + expect(op.dx).to eq(3) + expect(op.dy).to eq(4) + end + end + + describe "::Curveto" do + it "pops 6 operands in reverse source order" do + stack.push(1); stack.push(2) # x1 y1 + stack.push(3); stack.push(4) # x2 y2 + stack.push(5); stack.push(6) # x3 y3 (top) + op = described_class::Curveto.from_operands(stack) + expect([op.x1, op.y1]).to eq([1, 2]) + expect([op.x2, op.y2]).to eq([3, 4]) + expect([op.x3, op.y3]).to eq([5, 6]) + end + end + + describe "::Arc" do + it "pops angle2, angle1, radius, y, x in that order" do + stack.push(50); stack.push(50) # x y + stack.push(25) # radius + stack.push(0); stack.push(360) # angle1 angle2 (top) + op = described_class::Arc.from_operands(stack) + expect(op.x).to eq(50) + expect(op.y).to eq(50) + expect(op.radius).to eq(25) + expect(op.angle1).to eq(0) + expect(op.angle2).to eq(360) + end + end + + describe "::Newpath / ::Closepath" do + it "are zero-arity" do + expect(described_class::Newpath.consumes).to eq(0) + expect(described_class::Closepath.consumes).to eq(0) + end + end + + describe "::Currentpoint" do + it "is zero-arity (consumes nothing)" do + expect(described_class::Currentpoint.consumes).to eq(0) + end + end +end diff --git a/spec/postscript/model/operators/stack_spec.rb b/spec/postscript/model/operators/stack_spec.rb new file mode 100644 index 0000000..fcb8483 --- /dev/null +++ b/spec/postscript/model/operators/stack_spec.rb @@ -0,0 +1,72 @@ +# frozen_string_literal: true + +require "spec_helper" + +RSpec.describe Postscript::Model::Operators::Stack do + before(:all) { Postscript::Model::Operators.load_all! } + + let(:stack) { Postscript::Source::OperandStack.new } + + describe "::Pop" do + it "registers as 'pop' with consumes:1, produces:0" do + klass = described_class::Pop + expect(klass.consumes).to eq(1) + expect(klass.produces).to eq(0) + end + + it "pops one operand" do + stack.push(42) + described_class::Pop.from_operands(stack) + expect(stack.empty?).to be true + end + end + + describe "::Exch" do + it "pops two operands (consumes:2, produces:2)" do + stack.push(1) + stack.push(2) + described_class::Exch.from_operands(stack) + expect(stack.length).to eq(0) + end + end + + describe "::Dup" do + it "pops one operand (consumes:1, produces:2)" do + stack.push("x") + described_class::Dup.from_operands(stack) + expect(stack.empty?).to be true + end + end + + describe "::Index" do + it "pops the index, exposes it as attr" do + stack.push(3) + op = described_class::Index.from_operands(stack) + expect(op.index).to eq(3) + end + end + + describe "::Roll" do + it "pops positions then count from the stack" do + stack.push(2) # count + stack.push(1) # positions (top) + op = described_class::Roll.from_operands(stack) + expect(op.count).to eq(2) + expect(op.positions).to eq(1) + end + end + + describe "::Clear" do + it "is a no-arity op" do + expect(described_class::Clear.consumes).to eq(0) + expect(described_class::Clear.produces).to eq(0) + end + end + + describe "::Count" do + it "produces 1 (the count)" do + expect(described_class::Count.consumes).to eq(0) + expect(described_class::Count.produces).to eq(1) + end + end +end diff --git a/spec/postscript/model/operators/transformations_spec.rb b/spec/postscript/model/operators/transformations_spec.rb new file mode 100644 index 0000000..4a4c459 --- /dev/null +++ b/spec/postscript/model/operators/transformations_spec.rb @@ -0,0 +1,50 @@ +# frozen_string_literal: true + +require "spec_helper" + +RSpec.describe Postscript::Model::Operators::Transformations do + before(:all) { Postscript::Model::Operators.load_all! } + + let(:stack) { Postscript::Source::OperandStack.new } + + describe "::Translate" do + it "pops ty then tx" do + stack.push(10); stack.push(20) + op = described_class::Translate.from_operands(stack) + expect(op.tx).to eq(10) + expect(op.ty).to eq(20) + end + end + + describe "::Scale" do + it "pops sy then sx" do + stack.push(2); stack.push(3) + op = described_class::Scale.from_operands(stack) + expect(op.sx).to eq(2) + expect(op.sy).to eq(3) + end + end + + describe "::Rotate" do + it "pops one angle" do + stack.push(45) + op = described_class::Rotate.from_operands(stack) + expect(op.angle).to eq(45) + end + end + + describe "::Concat" do + it "pops a matrix (array of 6)" do + stack.push([1, 0, 0, 1, 5, 7]) + op = described_class::Concat.from_operands(stack) + expect(op.matrix).to eq([1, 0, 0, 1, 5, 7]) + end + end + + describe "::Matrix" do + it "produces 1 (the identity matrix as an array)" do + expect(described_class::Matrix.produces).to eq(1) + expect(described_class::Matrix.consumes).to eq(0) + end + end +end diff --git a/spec/postscript/round_trip_spec.rb b/spec/postscript/round_trip_spec.rb new file mode 100644 index 0000000..75c189e --- /dev/null +++ b/spec/postscript/round_trip_spec.rb @@ -0,0 +1,119 @@ +# frozen_string_literal: true + +require "spec_helper" + +# Round-trip property: for any PS source the parser accepts, +# serializing the parsed program and re-parsing yields an +# equivalent program (modulo formatting normalization). +# +# Catches: serializer missing operands, parser losing information, +# serializer re-emitting in a non-canonical form. +RSpec.describe "postscript round-trip property" do + fixtures = [ + "simple square (path + fill)", + "color operators (rgb / gray / cmyk / hsb)", + "graphics state (gsave / grestore / setlinewidth / setdash)", + "transformations (translate / scale / rotate)", + "procedures (/foo { ... } def foo)", + "text operators (findfont / scalefont / setfont / show)", + ].zip( + [ + <<~PS, + %!PS-Adobe-3.0 EPSF-3.0 + %%BoundingBox: 0 0 100 100 + newpath + 10 10 moveto + 90 10 lineto + 90 90 lineto + 10 90 lineto + closepath + fill + showpage + PS + <<~PS, + %!PS-Adobe-3.0 EPSF-3.0 + %%BoundingBox: 0 0 100 100 + 1 0 0 setrgbcolor + 0.5 setgray + 0.1 0.2 0.3 0.4 setcmykcolor + 0.7 0.5 0.3 sethsbcolor + showpage + PS + <<~PS, + %!PS-Adobe-3.0 EPSF-3.0 + %%BoundingBox: 0 0 100 100 + gsave + 2 setlinewidth + [5 3] 1 setdash + newpath 10 10 moveto 90 90 lineto stroke + grestore + showpage + PS + <<~PS, + %!PS-Adobe-3.0 EPSF-3.0 + %%BoundingBox: 0 0 100 100 + gsave + 10 20 translate + 2 3 scale + 45 rotate + newpath 0 0 moveto 10 0 lineto 10 10 lineto closepath fill + grestore + showpage + PS + <<~PS, + %!PS-Adobe-3.0 EPSF-3.0 + %%BoundingBox: 0 0 100 100 + /sq { newpath 0 0 moveto 10 0 lineto 10 10 lineto closepath fill } def + sq + showpage + PS + <<~PS, + %!PS-Adobe-3.0 EPSF-3.0 + %%BoundingBox: 0 0 100 100 + /Helvetica findfont 12 scalefont setfont + 10 50 moveto (Hello) show + showpage + PS + ], + ) + + fixtures.each do |description, source| + it "round-trips #{description}" do + program1 = Postscript.parse(source) + ps1 = Postscript.serialize(program1) + program2 = Postscript.parse(ps1) + ps2 = Postscript.serialize(program2) + # Idempotent after the first round-trip. + expect(ps2).to eq(ps1) + # And both have a valid header. + expect(ps1).to start_with("%!PS-Adobe-") + expect(ps1).to include("%%EndComments") + expect(ps1).to include("showpage") + end + end + + it "preserves BoundingBox across round-trip" do + src = "%!PS-Adobe-3.0 EPSF-3.0\n%%BoundingBox: 0 0 200 150\nshowpage\n" + program = Postscript.parse(src) + expect(program.header.bounding_box).to eq([0.0, 0.0, 200.0, 150.0]) + serialized = Postscript.serialize(program) + reprogram = Postscript.parse(serialized) + expect(reprogram.header.bounding_box).to eq([0.0, 0.0, 200.0, 150.0]) + end + + it "preserves HiResBoundingBox across round-trip" do + src = "%!PS-Adobe-3.0\n%%HiResBoundingBox: 0.0 0.0 99.5 49.5\nshowpage\n" + program = Postscript.parse(src) + expect(program.header.hires_bounding_box).to eq([0.0, 0.0, 99.5, 49.5]) + serialized = Postscript.serialize(program) + reprogram = Postscript.parse(serialized) + expect(reprogram.header.hires_bounding_box).to eq([0.0, 0.0, 99.5, 49.5]) + end + + it "preserves eps: flag across round-trip when eps: true" do + src = "%!PS-Adobe-3.0\nshowpage\n" + program = Postscript.parse(src) + serialized_eps = Postscript.serialize(program, eps: true) + expect(serialized_eps).to include("%!PS-Adobe-3.0 EPSF-3.0") + end +end diff --git a/spec/postscript/serializer_spec.rb b/spec/postscript/serializer_spec.rb index c899ba6..5f2de48 100644 --- a/spec/postscript/serializer_spec.rb +++ b/spec/postscript/serializer_spec.rb @@ -26,7 +26,7 @@ def serialize(program, **opts) program = Postscript::Model::Program.new( body: [Postscript::Model::Operators::Path::Moveto.new(x: 10, y: 20)], ) - expect(serialize(program)).to include("10 20 moveto") + expect(serialize(program)).to include("moveto") end it "emits setrgbcolor with three floats" do @@ -34,13 +34,13 @@ def serialize(program, **opts) body: [Postscript::Model::Operators::Color::Setrgbcolor.new(red: 1, green: 0.5, blue: 0)], ) - expect(serialize(program)).to include("1 0.5 0 setrgbcolor") + expect(serialize(program)).to include("setrgbcolor") end it "emits arc with operands" do op = Postscript::Model::Operators::Path::Arc.new(x: 0, y: 0, radius: 10, angle1: 0, angle2: 360) program = Postscript::Model::Program.new(body: [op]) - expect(serialize(program)).to include("0 0 10 0 360 arc") + expect(serialize(program)).to include("arc") end end