postsvg 0.2.6 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/.rubocop_todo.yml +49 -533
- data/CLAUDE.md +3 -27
- data/Gemfile +2 -2
- data/README.adoc +4 -62
- data/Rakefile +0 -17
- data/lib/postsvg/cli.rb +5 -10
- data/lib/postsvg/color.rb +132 -0
- data/lib/postsvg/format_number.rb +22 -0
- data/lib/postsvg/graphics_context.rb +1 -2
- data/lib/postsvg/interpreter.rb +2 -1
- data/lib/postsvg/matrix.rb +106 -0
- data/lib/postsvg/model/literals/array.rb +41 -0
- data/lib/postsvg/model/literals/dictionary.rb +34 -0
- data/lib/postsvg/model/literals/hex.rb +37 -0
- data/lib/postsvg/model/literals/name.rb +40 -0
- data/lib/postsvg/model/literals/number.rb +36 -0
- data/lib/postsvg/model/literals/procedure.rb +41 -0
- data/lib/postsvg/model/literals/string.rb +30 -0
- data/lib/postsvg/model/literals.rb +19 -0
- data/lib/postsvg/model/operator.rb +58 -0
- data/lib/postsvg/model/operators/arithmetic.rb +264 -0
- data/lib/postsvg/model/operators/boolean.rb +182 -0
- data/lib/postsvg/model/operators/color.rb +74 -0
- data/lib/postsvg/model/operators/container.rb +186 -0
- data/lib/postsvg/model/operators/control_flow.rb +119 -0
- data/lib/postsvg/model/operators/device.rb +21 -0
- data/lib/postsvg/model/operators/dictionary.rb +118 -0
- data/lib/postsvg/model/operators/font.rb +121 -0
- data/lib/postsvg/model/operators/graphics_state.rb +84 -0
- data/lib/postsvg/model/operators/painting.rb +29 -0
- data/lib/postsvg/model/operators/path.rb +169 -0
- data/lib/postsvg/model/operators/stack.rb +72 -0
- data/lib/postsvg/model/operators/transformations.rb +103 -0
- data/lib/postsvg/model/operators.rb +89 -0
- data/lib/postsvg/model/program.rb +68 -0
- data/lib/postsvg/model/token.rb +43 -0
- data/lib/postsvg/model.rb +17 -0
- data/lib/postsvg/options.rb +1 -1
- data/lib/postsvg/parser/postscript_parser.rb +7 -7
- data/lib/postsvg/renderer.rb +3 -7
- data/lib/postsvg/serializer.rb +325 -0
- data/lib/postsvg/source/ast_builder.rb +308 -0
- data/lib/postsvg/source/lexer.rb +322 -0
- data/lib/postsvg/source/operand_stack.rb +55 -0
- data/lib/postsvg/source.rb +21 -0
- data/lib/postsvg/svg/clip_path_registry.rb +1 -3
- data/lib/postsvg/svg/element.rb +2 -4
- data/lib/postsvg/svg/elements/ellipse.rb +1 -2
- data/lib/postsvg/svg/elements/svg.rb +1 -2
- data/lib/postsvg/svg/paint.rb +1 -1
- data/lib/postsvg/svg/parser.rb +3 -3
- data/lib/postsvg/svg/path_data/parser.rb +6 -10
- data/lib/postsvg/svg/stroke.rb +2 -4
- data/lib/postsvg/svg/transform_list.rb +1 -1
- data/lib/postsvg/svg.rb +1 -1
- data/lib/postsvg/svg_builder.rb +11 -15
- data/lib/postsvg/translation/arc_converter.rb +14 -23
- data/lib/postsvg/translation/bounding_box.rb +4 -7
- data/lib/postsvg/translation/handlers/circle_handler.rb +2 -2
- data/lib/postsvg/translation/handlers/ellipse_handler.rb +3 -8
- data/lib/postsvg/translation/handlers/group_handler.rb +1 -2
- data/lib/postsvg/translation/handlers/image_handler.rb +2 -3
- data/lib/postsvg/translation/handlers/line_handler.rb +2 -6
- data/lib/postsvg/translation/handlers/open_handler.rb +2 -3
- data/lib/postsvg/translation/handlers/path_handler.rb +60 -82
- data/lib/postsvg/translation/handlers/polygon_handler.rb +2 -4
- data/lib/postsvg/translation/handlers/polyline_handler.rb +4 -5
- data/lib/postsvg/translation/handlers/rect_handler.rb +5 -10
- data/lib/postsvg/translation/handlers/shared.rb +11 -11
- data/lib/postsvg/translation/handlers/svg_handler.rb +2 -3
- data/lib/postsvg/translation/handlers/text_handler.rb +8 -17
- data/lib/postsvg/translation/handlers.rb +1 -2
- data/lib/postsvg/translation/ps_renderer.rb +4 -18
- data/lib/postsvg/translation/record_emitter.rb +0 -54
- data/lib/postsvg/version.rb +1 -1
- data/lib/postsvg/visitors/ps_visitor/arithmetic.rb +6 -6
- data/lib/postsvg/visitors/ps_visitor/color.rb +5 -8
- data/lib/postsvg/visitors/ps_visitor/common.rb +2 -1
- data/lib/postsvg/visitors/ps_visitor/container.rb +13 -13
- data/lib/postsvg/visitors/ps_visitor/control_flow.rb +3 -3
- data/lib/postsvg/visitors/ps_visitor/dictionary.rb +4 -8
- data/lib/postsvg/visitors/ps_visitor/font.rb +4 -15
- data/lib/postsvg/visitors/ps_visitor/graphics_state.rb +5 -7
- data/lib/postsvg/visitors/ps_visitor/painting.rb +3 -3
- data/lib/postsvg/visitors/ps_visitor/path.rb +33 -52
- data/lib/postsvg/visitors/ps_visitor/transformations.rb +7 -45
- data/lib/postsvg/visitors/ps_visitor.rb +2 -18
- data/lib/postsvg.rb +38 -43
- metadata +39 -20
- data/gemfiles/native-parity/Gemfile +0 -8
data/CLAUDE.md
CHANGED
|
@@ -4,7 +4,7 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
|
|
|
4
4
|
|
|
5
5
|
## What this is
|
|
6
6
|
|
|
7
|
-
`postsvg` is a pure-Ruby bidirectional converter between PostScript/EPS and SVG. Both directions are implemented in the 0.2.x line; the SVg→PS direction is newer. The PS side uses a hand-written lexer + stack-machine interpreter; the SVG side uses
|
|
7
|
+
`postsvg` is a pure-Ruby bidirectional converter between PostScript/EPS and SVG. Both directions are implemented in the 0.2.x line; the SVg→PS direction is newer. The PS side uses a hand-written lexer + stack-machine interpreter; the SVG side uses Nokogiri. There is no Ghostscript, Inkscape, or other external renderer.
|
|
8
8
|
|
|
9
9
|
Code architecture mirrors `emfsvg`: a typed `Model::Program` value object is the single source of truth, with a Renderer / Visitor visiting it to emit SVG, and a Serializer walking it to emit PS.
|
|
10
10
|
|
|
@@ -27,30 +27,6 @@ bundle exec ruby exe/postsvg batch INPUT_DIR [OUTPUT_DIR]
|
|
|
27
27
|
bundle exec ruby exe/postsvg version
|
|
28
28
|
```
|
|
29
29
|
|
|
30
|
-
## Round-trip contract
|
|
31
|
-
|
|
32
|
-
The `round_trip` tag selects the model comparison, SVG summary comparison,
|
|
33
|
-
and byte-pinned emitted-PostScript golden checks:
|
|
34
|
-
|
|
35
|
-
```bash
|
|
36
|
-
bundle exec rspec --tag round_trip
|
|
37
|
-
bundle exec rake round_trip:regenerate_goldens
|
|
38
|
-
```
|
|
39
|
-
|
|
40
|
-
These specs also run in the normal CI suite. The regeneration task rewrites
|
|
41
|
-
the `.ps.golden` beside every SVG listed in
|
|
42
|
-
`spec/fixtures/round_trip/manifest.yml`. Regenerate only for intentional output
|
|
43
|
-
changes, inspect the diffs, and include them in a reviewed PR; never edit
|
|
44
|
-
goldens by hand.
|
|
45
|
-
|
|
46
|
-
Manifest `known_gap` and `non_goal` markers are **strict pending**, scoped to
|
|
47
|
-
the `model` or `svg` leg. They do not skip comparisons: an unexpected pass
|
|
48
|
-
fails the suite and requires reviewing/removing the marker. `known_gap`
|
|
49
|
-
identifies a bug requiring follow-up; `non_goal` records intentional loss
|
|
50
|
-
(currently gradient paint flattening). Unmarked legs must pass, and golden
|
|
51
|
-
checks apply even to marked fixtures. See the README Round-trip section for
|
|
52
|
-
the supported subset, comparison precision and parser-callable parity hook.
|
|
53
|
-
|
|
54
30
|
## Architecture
|
|
55
31
|
|
|
56
32
|
```
|
|
@@ -78,7 +54,7 @@ PS/EPS bytes ──▶│ Postsvg::Lexer │
|
|
|
78
54
|
│ │
|
|
79
55
|
▼ │
|
|
80
56
|
SVG string │
|
|
81
|
-
│
|
|
57
|
+
│ Nokogiri │
|
|
82
58
|
▼ │
|
|
83
59
|
┌─────────────────┐ │
|
|
84
60
|
│ Postsvg:: │ │
|
|
@@ -187,7 +163,7 @@ Errors: `Postsvg::ParseError`, `LexError`, `SyntaxError`, `RenderError`, `StackU
|
|
|
187
163
|
|
|
188
164
|
## Dependencies
|
|
189
165
|
|
|
190
|
-
- `parslet`, `thor`, `
|
|
166
|
+
- `parslet`, `thor`, `nokogiri`, `lutaml/canon` (dev only, for XML matcher in integration specs) — see `Gemfile`.
|
|
191
167
|
|
|
192
168
|
## Limitations
|
|
193
169
|
|
data/Gemfile
CHANGED
data/README.adoc
CHANGED
|
@@ -225,9 +225,8 @@ equivalent PS `arc` operator.
|
|
|
225
225
|
|
|
226
226
|
=== Round-trip
|
|
227
227
|
|
|
228
|
-
The
|
|
229
|
-
|
|
230
|
-
The conversion is a function composition:
|
|
228
|
+
The same `Model::Program` value object sits behind both directions, so
|
|
229
|
+
round-trip is a function composition:
|
|
231
230
|
|
|
232
231
|
[source,ruby]
|
|
233
232
|
----
|
|
@@ -237,67 +236,10 @@ original_svg = File.read("input.svg")
|
|
|
237
236
|
ps = Postsvg.to_ps(original_svg)
|
|
238
237
|
round_tripped_svg = Postsvg.to_svg(ps)
|
|
239
238
|
|
|
239
|
+
# round_tripped_svg preserves the geometry of original_svg for the
|
|
240
|
+
# supported subset of SVG elements.
|
|
240
241
|
----
|
|
241
242
|
|
|
242
|
-
The gate checks two legs independently:
|
|
243
|
-
|
|
244
|
-
* *Normalized model diff*: `RoundTrip::Harness` compares the program built
|
|
245
|
-
from SVG with the program parsed from its serialized PostScript. The
|
|
246
|
-
normalizer compares operators and operand values, rounding floats to the
|
|
247
|
-
serializer's four decimal places.
|
|
248
|
-
* *SVG summary diff*: `RoundTrip::SvgSummary` compares the original SVG with
|
|
249
|
-
the returned SVG as painted geometry in document coordinates. It checks
|
|
250
|
-
fill, stroke, stroke width, line caps and joins, transforms, and text
|
|
251
|
-
content, position, font family and size. Numbers are rounded to two decimal
|
|
252
|
-
places; equivalent shape/path spellings and the renderer's Y-flip wrapper
|
|
253
|
-
are normalized. This is not a byte-for-byte SVG or pixel-rendering test.
|
|
254
|
-
|
|
255
|
-
Covered families are shapes (rectangles, polygons, polylines, line and curve
|
|
256
|
-
paths), line strokes, arcs (circles, ellipses and path arcs), gray/RGB fills,
|
|
257
|
-
combined fill and stroke, stroke styles, text, transforms (translation,
|
|
258
|
-
rotation, matrices and nested groups), and gradients. Coverage does not mean
|
|
259
|
-
every family preserves everything: the manifest records known SVG-leg gaps
|
|
260
|
-
for polyline fill, path arcs and matrix shear. Text checks do not promise font
|
|
261
|
-
metrics or glyph fidelity. Features outside the corpus are not guaranteed by
|
|
262
|
-
this gate.
|
|
263
|
-
|
|
264
|
-
The manifest is authoritative for exceptions on each leg. `known_gap` marks
|
|
265
|
-
a bug; `non_goal` marks intentional loss. Linear and radial gradient `url()`
|
|
266
|
-
paint is intentionally flattened to a plain fill (currently black), so gradient
|
|
267
|
-
preservation is a non-goal even though the model leg passes. Both markers are
|
|
268
|
-
strict pending: the comparison still runs, and an unexpected pass fails so
|
|
269
|
-
the marker must be reviewed and removed when appropriate.
|
|
270
|
-
|
|
271
|
-
The `round_trip` spec tag selects the contract and emitted-PostScript golden
|
|
272
|
-
checks, which also run in the normal CI spec suite. Every manifest fixture has
|
|
273
|
-
a byte-pinned `.ps.golden`; even marked fixtures must match their golden.
|
|
274
|
-
For an intentional output change, regenerate with:
|
|
275
|
-
|
|
276
|
-
[source,sh]
|
|
277
|
-
----
|
|
278
|
-
bundle exec rake round_trip:regenerate_goldens
|
|
279
|
-
----
|
|
280
|
-
|
|
281
|
-
Review the resulting diffs and submit them with the change in a reviewed PR.
|
|
282
|
-
Do not edit goldens by hand or regenerate them merely to silence a regression.
|
|
283
|
-
|
|
284
|
-
For `postscript#4` parser parity, the test-support hook
|
|
285
|
-
`RoundTrip::Harness.call(svg, parser: candidate_parser)` accepts a callable
|
|
286
|
-
that receives serialized PS source and returns a compatible program. It
|
|
287
|
-
defaults to `Postscript::Source.parse` and returns an empty diff on a match,
|
|
288
|
-
or indexed expected/actual normalized records on a mismatch. This lets a
|
|
289
|
-
candidate parser reuse the same model contract without changing the public
|
|
290
|
-
conversion API.
|
|
291
|
-
|
|
292
|
-
The model contract also runs every manifest fixture with
|
|
293
|
-
`Postscript::Source.parse(source, engine: :native)`, comparing normalized
|
|
294
|
-
programs against both Ruby parsing and the emitted model. Run it with
|
|
295
|
-
`bundle exec rspec spec/postsvg/round_trip_contract_spec.rb --tag native`.
|
|
296
|
-
When the installed postscript lacks the native API or its optional parsanol
|
|
297
|
-
native engine is unavailable, examples skip with an explicit reason. These
|
|
298
|
-
skips do not establish native parity; see
|
|
299
|
-
`spec/fixtures/round_trip/README.adoc` for prerequisites and marker behavior.
|
|
300
|
-
|
|
301
243
|
== Command-line interface
|
|
302
244
|
|
|
303
245
|
[[cli]]
|
data/Rakefile
CHANGED
|
@@ -3,29 +3,12 @@
|
|
|
3
3
|
require "bundler/gem_tasks"
|
|
4
4
|
require "rspec/core/rake_task"
|
|
5
5
|
require "rubocop/rake_task"
|
|
6
|
-
require "yaml"
|
|
7
6
|
|
|
8
7
|
RSpec::Core::RakeTask.new(:spec)
|
|
9
8
|
RuboCop::RakeTask.new
|
|
10
9
|
|
|
11
10
|
task default: %i[spec rubocop]
|
|
12
11
|
|
|
13
|
-
namespace :round_trip do
|
|
14
|
-
desc "Regenerate pinned PostScript output for the round-trip corpus"
|
|
15
|
-
task :regenerate_goldens do
|
|
16
|
-
require "postsvg"
|
|
17
|
-
|
|
18
|
-
root = File.expand_path("spec/fixtures/round_trip", __dir__)
|
|
19
|
-
manifest = YAML.safe_load_file(File.join(root, "manifest.yml"))
|
|
20
|
-
|
|
21
|
-
manifest.fetch("fixtures").each do |entry|
|
|
22
|
-
svg_path = File.join(root, entry.fetch("file"))
|
|
23
|
-
golden_path = svg_path.sub(/\.svg\z/, ".ps.golden")
|
|
24
|
-
File.binwrite(golden_path, Postsvg.to_ps(File.read(svg_path)))
|
|
25
|
-
end
|
|
26
|
-
end
|
|
27
|
-
end
|
|
28
|
-
|
|
29
12
|
# Link checking tasks
|
|
30
13
|
namespace :check do
|
|
31
14
|
desc "Check internal documentation links only (recommended for CI)"
|
data/lib/postsvg/cli.rb
CHANGED
|
@@ -8,8 +8,7 @@ module Postsvg
|
|
|
8
8
|
class CLI < Thor
|
|
9
9
|
package_name "postsvg"
|
|
10
10
|
|
|
11
|
-
desc "convert INPUT [OUTPUT]",
|
|
12
|
-
"Convert PostScript/EPS file to SVG (legacy alias for to-svg)"
|
|
11
|
+
desc "convert INPUT [OUTPUT]", "Convert PostScript/EPS file to SVG (legacy alias for to-svg)"
|
|
13
12
|
long_desc <<~DESC
|
|
14
13
|
Convert a PostScript (.ps) or Encapsulated PostScript (.eps) file to SVG.
|
|
15
14
|
|
|
@@ -21,8 +20,7 @@ module Postsvg
|
|
|
21
20
|
$ postsvg convert input.eps > output.svg
|
|
22
21
|
DESC
|
|
23
22
|
def convert(input_path, output_path = nil)
|
|
24
|
-
handle_input(input_path, output_path, method(:read_ps_to_svg),
|
|
25
|
-
direction: "PS/EPS -> SVG")
|
|
23
|
+
handle_input(input_path, output_path, method(:read_ps_to_svg), direction: "PS/EPS -> SVG")
|
|
26
24
|
end
|
|
27
25
|
|
|
28
26
|
desc "to-svg INPUT [OUTPUT]", "Convert PostScript/EPS to SVG"
|
|
@@ -44,8 +42,7 @@ module Postsvg
|
|
|
44
42
|
$ postsvg to-ps input.svg > output.ps
|
|
45
43
|
DESC
|
|
46
44
|
def to_ps(input_path, output_path = nil)
|
|
47
|
-
handle_input(input_path, output_path, method(:read_svg_to_ps),
|
|
48
|
-
direction: "SVG -> PS")
|
|
45
|
+
handle_input(input_path, output_path, method(:read_svg_to_ps), direction: "SVG -> PS")
|
|
49
46
|
end
|
|
50
47
|
|
|
51
48
|
desc "to-eps INPUT [OUTPUT]", "Convert SVG to Encapsulated PostScript"
|
|
@@ -54,12 +51,10 @@ module Postsvg
|
|
|
54
51
|
suitable for embedding.
|
|
55
52
|
DESC
|
|
56
53
|
def to_eps(input_path, output_path = nil)
|
|
57
|
-
handle_input(input_path, output_path, method(:read_svg_to_eps),
|
|
58
|
-
direction: "SVG -> EPS")
|
|
54
|
+
handle_input(input_path, output_path, method(:read_svg_to_eps), direction: "SVG -> EPS")
|
|
59
55
|
end
|
|
60
56
|
|
|
61
|
-
desc "batch INPUT_DIR [OUTPUT_DIR]",
|
|
62
|
-
"Convert all PS/EPS/SVG files in a directory"
|
|
57
|
+
desc "batch INPUT_DIR [OUTPUT_DIR]", "Convert all PS/EPS/SVG files in a directory"
|
|
63
58
|
long_desc <<~DESC
|
|
64
59
|
Auto-detects direction by file extension:
|
|
65
60
|
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Postsvg
|
|
4
|
+
# Immutable RGB color value object. Use the constructors (+.rgb+,
|
|
5
|
+
# +.gray+, +.cmyk+, +.parse+) instead of +new+ when constructing from
|
|
6
|
+
# other color models; they normalize to integer [0, 255] channels.
|
|
7
|
+
class Color
|
|
8
|
+
include Comparable
|
|
9
|
+
|
|
10
|
+
attr_reader :red, :green, :blue
|
|
11
|
+
|
|
12
|
+
def self.clamp_byte(value)
|
|
13
|
+
Integer === value ? value.clamp(0, 255) : value.to_i.clamp(0, 255)
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def self.scale_unit_to_byte(value)
|
|
17
|
+
(value.clamp(0.0, 1.0) * 255.0).round
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def initialize(red, green, blue)
|
|
21
|
+
@red = Color.clamp_byte(red)
|
|
22
|
+
@green = Color.clamp_byte(green)
|
|
23
|
+
@blue = Color.clamp_byte(blue)
|
|
24
|
+
freeze
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
# RGB triple in PS-native [0.0, 1.0] floats.
|
|
28
|
+
def self.rgb(r, g, b)
|
|
29
|
+
new(scale_unit_to_byte(r), scale_unit_to_byte(g), scale_unit_to_byte(b))
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
# Gray in [0.0, 1.0].
|
|
33
|
+
def self.gray(level)
|
|
34
|
+
v = scale_unit_to_byte(level)
|
|
35
|
+
new(v, v, v)
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# CMYK in [0.0, 1.0]. Conversion matches PLRM §8.2.4 simplified
|
|
39
|
+
# formula; not colorimetrically exact.
|
|
40
|
+
def self.cmyk(c, m, y, k)
|
|
41
|
+
new(
|
|
42
|
+
scale_unit_to_byte((1 - c) * (1 - k)),
|
|
43
|
+
scale_unit_to_byte((1 - m) * (1 - k)),
|
|
44
|
+
scale_unit_to_byte((1 - y) * (1 - k)),
|
|
45
|
+
)
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
# Parse CSS / SVG color notations: #rgb, #rrggbb, rgb(r, g, b),
|
|
49
|
+
# rgb(r g b / a), named colors (X11 subset).
|
|
50
|
+
def self.parse(text)
|
|
51
|
+
return text if text.is_a?(Color)
|
|
52
|
+
|
|
53
|
+
case text.to_s.downcase
|
|
54
|
+
when /\A#([0-9a-f]{6})\z/
|
|
55
|
+
hex = ::Regexp.last_match(1)
|
|
56
|
+
new(hex[0, 2].to_i(16), hex[2, 2].to_i(16), hex[4, 2].to_i(16))
|
|
57
|
+
when /\A#([0-9a-f]{3})\z/
|
|
58
|
+
hex = ::Regexp.last_match(1)
|
|
59
|
+
new((hex[0] * 2).to_i(16), (hex[1] * 2).to_i(16), (hex[2] * 2).to_i(16))
|
|
60
|
+
when /\argb\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)/
|
|
61
|
+
new(::Regexp.last_match(1).to_i, ::Regexp.last_match(2).to_i, ::Regexp.last_match(3).to_i)
|
|
62
|
+
when /\argb\(\s*(\d+)\s+(\d+)\s+(\d+)/
|
|
63
|
+
new(::Regexp.last_match(1).to_i, ::Regexp.last_match(2).to_i, ::Regexp.last_match(3).to_i)
|
|
64
|
+
when "none", "transparent"
|
|
65
|
+
nil
|
|
66
|
+
else
|
|
67
|
+
rgb = NAMED_COLORS[text.to_s]
|
|
68
|
+
return new(*rgb) if rgb
|
|
69
|
+
|
|
70
|
+
raise ArgumentError, "unrecognized color: #{text.inspect}"
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def gray?
|
|
75
|
+
red == green && green == blue
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def gray_level
|
|
79
|
+
(red + green + blue) / 765.0
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def to_rgb_unit
|
|
83
|
+
[red / 255.0, green / 255.0, blue / 255.0]
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def to_svg
|
|
87
|
+
format("#%02x%02x%02x", red, green, blue)
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def to_ps_rgb
|
|
91
|
+
to_rgb_unit.map { |c| FormatNumber.call(c) }.join(" ")
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def to_ps_gray
|
|
95
|
+
raise ArgumentError, "color is not gray" unless gray?
|
|
96
|
+
|
|
97
|
+
FormatNumber.call(gray_level)
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
def <=>(other)
|
|
101
|
+
return nil unless other.is_a?(Color)
|
|
102
|
+
|
|
103
|
+
[red, green, blue] <=> [other.red, other.green, other.blue]
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
def hash
|
|
107
|
+
[red, green, blue].hash
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
def eql?(other)
|
|
111
|
+
other.is_a?(Color) && other.red == red && other.green == green && other.blue == blue
|
|
112
|
+
end
|
|
113
|
+
alias == eql?
|
|
114
|
+
|
|
115
|
+
NAMED_COLORS = {
|
|
116
|
+
"black" => [0, 0, 0], "white" => [255, 255, 255],
|
|
117
|
+
"red" => [255, 0, 0], "green" => [0, 128, 0], "blue" => [0, 0, 255],
|
|
118
|
+
"yellow" => [255, 255, 0], "cyan" => [0, 255, 255],
|
|
119
|
+
"magenta" => [255, 0, 255], "gray" => [128, 128, 128],
|
|
120
|
+
"grey" => [128, 128, 128], "silver" => [192, 192, 192],
|
|
121
|
+
"maroon" => [128, 0, 0], "olive" => [128, 128, 0],
|
|
122
|
+
"navy" => [0, 0, 128], "purple" => [128, 0, 128],
|
|
123
|
+
"teal" => [0, 128, 128], "lime" => [0, 255, 0],
|
|
124
|
+
"aqua" => [0, 255, 255], "fuchsia" => [255, 0, 255],
|
|
125
|
+
"orange" => [255, 165, 0], "pink" => [255, 192, 203],
|
|
126
|
+
"brown" => [165, 42, 42],
|
|
127
|
+
}.freeze
|
|
128
|
+
|
|
129
|
+
BLACK = Color.new(0, 0, 0).freeze
|
|
130
|
+
WHITE = Color.new(255, 255, 255).freeze
|
|
131
|
+
end
|
|
132
|
+
end
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Postsvg
|
|
4
|
+
# Single source of truth for formatting numbers in both directions.
|
|
5
|
+
#
|
|
6
|
+
# - Integers print without a trailing ".0".
|
|
7
|
+
# - Floats print to 4 decimals, trailing zeros stripped.
|
|
8
|
+
# - -0 normalizes to 0 (so viewBoxes don't read "0 -0 100 100").
|
|
9
|
+
# - +Inf / -Inf / NaN raise ArgumentError; callers should validate.
|
|
10
|
+
module FormatNumber
|
|
11
|
+
module_function
|
|
12
|
+
|
|
13
|
+
def call(value)
|
|
14
|
+
raise ArgumentError, "non-finite number: #{value.inspect}" unless value.finite?
|
|
15
|
+
|
|
16
|
+
normalized = value.zero? ? 0.0 : value
|
|
17
|
+
return normalized.to_i.to_s if normalized == normalized.to_i
|
|
18
|
+
|
|
19
|
+
format("%.4f", normalized).sub(/\.?0+$/, "")
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
end
|
|
@@ -60,8 +60,7 @@ module Postsvg
|
|
|
60
60
|
font_name: overrides.fetch(:font_name, @font_name),
|
|
61
61
|
font_size: overrides.fetch(:font_size, @font_size),
|
|
62
62
|
clip_paths: overrides.fetch(:clip_paths, @clip_paths),
|
|
63
|
-
last_text_position: overrides.fetch(:last_text_position,
|
|
64
|
-
@last_text_position),
|
|
63
|
+
last_text_position: overrides.fetch(:last_text_position, @last_text_position),
|
|
65
64
|
fill_rule: overrides.fetch(:fill_rule, @fill_rule),
|
|
66
65
|
)
|
|
67
66
|
end
|
data/lib/postsvg/interpreter.rb
CHANGED
|
@@ -334,8 +334,9 @@ module Postsvg
|
|
|
334
334
|
end
|
|
335
335
|
|
|
336
336
|
def flush_path(mode)
|
|
337
|
-
# rubocop:disable
|
|
337
|
+
# rubocop:disable Style/ZeroLengthPredicate
|
|
338
338
|
return if @path.length.zero?
|
|
339
|
+
# rubocop:enable Style/ZeroLengthPredicate
|
|
339
340
|
|
|
340
341
|
d = @path.to_path
|
|
341
342
|
path_str = emit_svg_path(d, @g_state, mode)
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Postsvg
|
|
4
|
+
# Matrix transformation class for PostScript coordinate transformations
|
|
5
|
+
# Implements affine transformation matrix [a b c d e f]
|
|
6
|
+
class Matrix
|
|
7
|
+
attr_accessor :a, :b, :c, :d, :e, :f
|
|
8
|
+
|
|
9
|
+
def initialize(a: 1, b: 0, c: 0, d: 1, e: 0, f: 0)
|
|
10
|
+
@a = a
|
|
11
|
+
@b = b
|
|
12
|
+
@c = c
|
|
13
|
+
@d = d
|
|
14
|
+
@e = e
|
|
15
|
+
@f = f
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def multiply(matrix)
|
|
19
|
+
result = Matrix.new
|
|
20
|
+
result.a = (@a * matrix.a) + (@c * matrix.b)
|
|
21
|
+
result.b = (@b * matrix.a) + (@d * matrix.b)
|
|
22
|
+
result.c = (@a * matrix.c) + (@c * matrix.d)
|
|
23
|
+
result.d = (@b * matrix.c) + (@d * matrix.d)
|
|
24
|
+
result.e = (@a * matrix.e) + (@c * matrix.f) + @e
|
|
25
|
+
result.f = (@b * matrix.e) + (@d * matrix.f) + @f
|
|
26
|
+
result
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def translate(tx, ty)
|
|
30
|
+
multiply(Matrix.new(e: tx, f: ty))
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def scale(sx, sy)
|
|
34
|
+
multiply(Matrix.new(a: sx, d: sy))
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def rotate(degrees)
|
|
38
|
+
radians = degrees * Math::PI / 180.0
|
|
39
|
+
m = Matrix.new
|
|
40
|
+
m.a = Math.cos(radians)
|
|
41
|
+
m.b = Math.sin(radians)
|
|
42
|
+
m.c = -Math.sin(radians)
|
|
43
|
+
m.d = Math.cos(radians)
|
|
44
|
+
multiply(m)
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def skew_x(angle)
|
|
48
|
+
radians = angle * Math::PI / 180.0
|
|
49
|
+
multiply(Matrix.new(c: Math.tan(radians)))
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def skew_y(angle)
|
|
53
|
+
radians = angle * Math::PI / 180.0
|
|
54
|
+
multiply(Matrix.new(b: Math.tan(radians)))
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def to_transform_string
|
|
58
|
+
"matrix(#{@a} #{@b} #{@c} #{@d} #{@e} #{@f})"
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def apply_point(x, y)
|
|
62
|
+
{
|
|
63
|
+
x: (x * @a) + (y * @c) + @e,
|
|
64
|
+
y: (x * @b) + (y * @d) + @f,
|
|
65
|
+
}
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def decompose
|
|
69
|
+
scale_x = Math.hypot(@a, @b)
|
|
70
|
+
scale_y = ((@a * @d) - (@b * @c)) / scale_x
|
|
71
|
+
|
|
72
|
+
rotation = Math.atan2(@b, @a) * (180.0 / Math::PI)
|
|
73
|
+
|
|
74
|
+
skew_x = Math.atan2((@a * @c) + (@b * @d), scale_x * scale_x)
|
|
75
|
+
skew_y = Math.atan2((@a * @b) + (@c * @d), scale_y * scale_y)
|
|
76
|
+
|
|
77
|
+
{
|
|
78
|
+
translate: { x: @e, y: @f },
|
|
79
|
+
scale: { x: scale_x, y: scale_y },
|
|
80
|
+
rotate: rotation,
|
|
81
|
+
skew: {
|
|
82
|
+
x: skew_x * (180.0 / Math::PI),
|
|
83
|
+
y: skew_y * (180.0 / Math::PI),
|
|
84
|
+
},
|
|
85
|
+
}
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def invert
|
|
89
|
+
det = (@a * @d) - (@b * @c)
|
|
90
|
+
return Matrix.new if det.abs < 1e-10
|
|
91
|
+
|
|
92
|
+
inv = Matrix.new
|
|
93
|
+
inv.a = @d / det
|
|
94
|
+
inv.b = -@b / det
|
|
95
|
+
inv.c = -@c / det
|
|
96
|
+
inv.d = @a / det
|
|
97
|
+
inv.e = ((@c * @f) - (@d * @e)) / det
|
|
98
|
+
inv.f = ((@b * @e) - (@a * @f)) / det
|
|
99
|
+
inv
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
def identity?
|
|
103
|
+
@a == 1 && @b.zero? && @c.zero? && @d == 1 && @e.zero? && @f.zero?
|
|
104
|
+
end
|
|
105
|
+
end
|
|
106
|
+
end
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Postsvg
|
|
4
|
+
module Model
|
|
5
|
+
module Literals
|
|
6
|
+
# Bracketed array literal: +[1 2 3]+. Elements may be any
|
|
7
|
+
# literal or operator.
|
|
8
|
+
class ArrayLiteral
|
|
9
|
+
include Enumerable
|
|
10
|
+
|
|
11
|
+
attr_reader :elements
|
|
12
|
+
|
|
13
|
+
def initialize(elements = [])
|
|
14
|
+
@elements = elements.freeze
|
|
15
|
+
freeze
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def each(&block)
|
|
19
|
+
@elements.each(&block)
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def length = @elements.length
|
|
23
|
+
def empty? = @elements.empty?
|
|
24
|
+
def [](idx) = @elements[idx]
|
|
25
|
+
|
|
26
|
+
def accept(visitor, ctx)
|
|
27
|
+
visitor.visit_array(self, ctx)
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def ==(other)
|
|
31
|
+
other.is_a?(ArrayLiteral) && other.elements == @elements
|
|
32
|
+
end
|
|
33
|
+
alias eql? ==
|
|
34
|
+
|
|
35
|
+
def hash
|
|
36
|
+
@elements.hash
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
end
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Postsvg
|
|
4
|
+
module Model
|
|
5
|
+
module Literals
|
|
6
|
+
# Inline dictionary literal: +<< /Key (value) /Num 42 >>+.
|
|
7
|
+
class Dictionary
|
|
8
|
+
attr_reader :entries
|
|
9
|
+
|
|
10
|
+
def initialize(entries = {})
|
|
11
|
+
@entries = entries.dup.freeze
|
|
12
|
+
freeze
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def [](key) = @entries[key]
|
|
16
|
+
def key?(key) = @entries.key?(key)
|
|
17
|
+
def each(&block) = @entries.each(&block)
|
|
18
|
+
|
|
19
|
+
def accept(visitor, ctx)
|
|
20
|
+
visitor.visit_dictionary(self, ctx)
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def ==(other)
|
|
24
|
+
other.is_a?(Dictionary) && other.entries == @entries
|
|
25
|
+
end
|
|
26
|
+
alias eql? ==
|
|
27
|
+
|
|
28
|
+
def hash
|
|
29
|
+
@entries.hash
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
end
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Postsvg
|
|
4
|
+
module Model
|
|
5
|
+
module Literals
|
|
6
|
+
# Hex-string literal: +<DEADBEEF>+.
|
|
7
|
+
class HexLiteral
|
|
8
|
+
attr_reader :value
|
|
9
|
+
|
|
10
|
+
def initialize(value)
|
|
11
|
+
@value = value.to_s
|
|
12
|
+
freeze
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
# Raw bytes encoded by the hex literal.
|
|
16
|
+
def bytes
|
|
17
|
+
hex = value.delete("^0-9a-fA-F")
|
|
18
|
+
hex << "0" if hex.length.odd?
|
|
19
|
+
hex.chars.each_slice(2).map { |pair| pair.join.to_i(16) }.pack("C*")
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def accept(visitor, ctx)
|
|
23
|
+
visitor.visit_hex(self, ctx)
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def ==(other)
|
|
27
|
+
other.is_a?(HexLiteral) && other.value == value
|
|
28
|
+
end
|
|
29
|
+
alias eql? ==
|
|
30
|
+
|
|
31
|
+
def hash
|
|
32
|
+
value.hash
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
end
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Postsvg
|
|
4
|
+
module Model
|
|
5
|
+
module Literals
|
|
6
|
+
# A PostScript name. +literal:+ distinguishes +/foo+ (literal,
|
|
7
|
+
# pushed onto the stack) from +foo+ (executable, looked up and
|
|
8
|
+
# executed).
|
|
9
|
+
class Name
|
|
10
|
+
attr_reader :value, :literal
|
|
11
|
+
|
|
12
|
+
def initialize(value, literal: false)
|
|
13
|
+
@value = value.to_s
|
|
14
|
+
@literal = literal
|
|
15
|
+
freeze
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def literal? = literal
|
|
19
|
+
def executable? = !literal
|
|
20
|
+
|
|
21
|
+
def to_s
|
|
22
|
+
@value
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def accept(visitor, ctx)
|
|
26
|
+
visitor.visit_name(self, ctx)
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def ==(other)
|
|
30
|
+
other.is_a?(Name) && other.value == value && other.literal == literal
|
|
31
|
+
end
|
|
32
|
+
alias eql? ==
|
|
33
|
+
|
|
34
|
+
def hash
|
|
35
|
+
[value, literal].hash
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
end
|