postsvg 0.2.5 → 0.2.6
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/CLAUDE.md +24 -0
- data/Gemfile +1 -1
- data/README.adoc +62 -4
- data/Rakefile +17 -0
- data/gemfiles/native-parity/Gemfile +8 -0
- data/lib/postsvg/interpreter.rb +1 -2
- data/lib/postsvg/translation/arc_converter.rb +1 -2
- data/lib/postsvg/translation/handlers/path_handler.rb +25 -19
- data/lib/postsvg/translation/handlers/polyline_handler.rb +1 -2
- data/lib/postsvg/translation/handlers/text_handler.rb +11 -3
- data/lib/postsvg/translation/ps_renderer.rb +16 -3
- data/lib/postsvg/translation/record_emitter.rb +49 -6
- data/lib/postsvg/version.rb +1 -1
- data/lib/postsvg/visitors/ps_visitor/font.rb +1 -0
- data/lib/postsvg/visitors/ps_visitor/graphics_state.rb +3 -1
- data/lib/postsvg/visitors/ps_visitor/painting.rb +2 -2
- data/lib/postsvg/visitors/ps_visitor/path.rb +50 -36
- data/lib/postsvg/visitors/ps_visitor/transformations.rb +45 -7
- data/lib/postsvg/visitors/ps_visitor.rb +18 -2
- metadata +7 -6
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 5f7f5030dcfb8b5b1f0cc990113f3e01c4dcd361f4c2adba3be90c887bddcd14
|
|
4
|
+
data.tar.gz: 8adfa25722485014bc085b0b04967da252c987ebd55535cae1e203a3ea847241
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 1387c2ac6841339ed6fe3c7f3027b6be26acca97e7cc3cc1c1b5a391113e2088c2ebc384f483993def51b0330a9d59afb07e10c4e9b39113875348203f257fbb
|
|
7
|
+
data.tar.gz: 68b2738560bd07c41688418463ca2f989004ae8cdab533a65774b241245d34d8868f2f5ae2b46b743d076ed5c5e17db2842b485f259fb4bce1b196fc121ad7bf
|
data/CLAUDE.md
CHANGED
|
@@ -27,6 +27,30 @@ 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
|
+
|
|
30
54
|
## Architecture
|
|
31
55
|
|
|
32
56
|
```
|
data/Gemfile
CHANGED
data/README.adoc
CHANGED
|
@@ -225,8 +225,9 @@ equivalent PS `arc` operator.
|
|
|
225
225
|
|
|
226
226
|
=== Round-trip
|
|
227
227
|
|
|
228
|
-
The
|
|
229
|
-
|
|
228
|
+
The round-trip contract covers a pinned SVG corpus, with per-fixture
|
|
229
|
+
expectations in link:spec/fixtures/round_trip/manifest.yml[manifest.yml].
|
|
230
|
+
The conversion is a function composition:
|
|
230
231
|
|
|
231
232
|
[source,ruby]
|
|
232
233
|
----
|
|
@@ -236,10 +237,67 @@ original_svg = File.read("input.svg")
|
|
|
236
237
|
ps = Postsvg.to_ps(original_svg)
|
|
237
238
|
round_tripped_svg = Postsvg.to_svg(ps)
|
|
238
239
|
|
|
239
|
-
# round_tripped_svg preserves the geometry of original_svg for the
|
|
240
|
-
# supported subset of SVG elements.
|
|
241
240
|
----
|
|
242
241
|
|
|
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
|
+
|
|
243
301
|
== Command-line interface
|
|
244
302
|
|
|
245
303
|
[[cli]]
|
data/Rakefile
CHANGED
|
@@ -3,12 +3,29 @@
|
|
|
3
3
|
require "bundler/gem_tasks"
|
|
4
4
|
require "rspec/core/rake_task"
|
|
5
5
|
require "rubocop/rake_task"
|
|
6
|
+
require "yaml"
|
|
6
7
|
|
|
7
8
|
RSpec::Core::RakeTask.new(:spec)
|
|
8
9
|
RuboCop::RakeTask.new
|
|
9
10
|
|
|
10
11
|
task default: %i[spec rubocop]
|
|
11
12
|
|
|
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
|
+
|
|
12
29
|
# Link checking tasks
|
|
13
30
|
namespace :check do
|
|
14
31
|
desc "Check internal documentation links only (recommended for CI)"
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
eval_gemfile "../../Gemfile"
|
|
4
|
+
|
|
5
|
+
# Drop this CI-only overlay once postscript releases NativeEngine and
|
|
6
|
+
# parsanol/parsanol-ruby#354 ships a fix for previously baked grammars.
|
|
7
|
+
gem "parsanol", "1.3.109"
|
|
8
|
+
gem "postscript", git: "https://github.com/claricle/postscript.git", branch: "main"
|
data/lib/postsvg/interpreter.rb
CHANGED
|
@@ -334,9 +334,8 @@ module Postsvg
|
|
|
334
334
|
end
|
|
335
335
|
|
|
336
336
|
def flush_path(mode)
|
|
337
|
-
# rubocop:disable Style/ZeroLengthPredicate
|
|
337
|
+
# rubocop:disable-next Style/ZeroLengthPredicate
|
|
338
338
|
return if @path.length.zero?
|
|
339
|
-
# rubocop:enable Style/ZeroLengthPredicate
|
|
340
339
|
|
|
341
340
|
d = @path.to_path
|
|
342
341
|
path_str = emit_svg_path(d, @g_state, mode)
|
|
@@ -11,7 +11,7 @@ module Postsvg
|
|
|
11
11
|
|
|
12
12
|
module_function
|
|
13
13
|
|
|
14
|
-
# rubocop:disable Metrics/AbcSize, Metrics/MethodLength, Metrics/ParameterLists
|
|
14
|
+
# rubocop:disable-next Metrics/AbcSize, Metrics/MethodLength, Metrics/ParameterLists
|
|
15
15
|
def endpoint_to_center(x1:, y1:, rx:, ry:, x_axis_rotation:,
|
|
16
16
|
large_arc:, sweep:, x2:, y2:)
|
|
17
17
|
rx = rx.abs
|
|
@@ -70,7 +70,6 @@ module Postsvg
|
|
|
70
70
|
|
|
71
71
|
[cx, cy, rx, ry, phi, theta1 * 180.0 / Math::PI, theta2 * 180.0 / Math::PI]
|
|
72
72
|
end
|
|
73
|
-
# rubocop:enable Metrics/AbcSize, Metrics/MethodLength, Metrics/ParameterLists
|
|
74
73
|
|
|
75
74
|
def angle_between(ux, uy, vx, vy)
|
|
76
75
|
len = vector_length(ux, uy, vx, vy)
|
|
@@ -9,8 +9,8 @@ module Postsvg
|
|
|
9
9
|
#
|
|
10
10
|
# - M/m moveto (relative repeats as l)
|
|
11
11
|
# - L/l lineto
|
|
12
|
-
# - H/h
|
|
13
|
-
# - V/v
|
|
12
|
+
# - H/h horizontal line
|
|
13
|
+
# - V/v vertical line
|
|
14
14
|
# - C/c cubic bezier
|
|
15
15
|
# - S/s smooth cubic (reflects previous control point)
|
|
16
16
|
# - Q/q quadratic bezier (converted to cubic)
|
|
@@ -50,7 +50,7 @@ module Postsvg
|
|
|
50
50
|
when "H", "h"
|
|
51
51
|
handle_hv(cmd, state, context, horizontal: cmd.opcode.upcase == "H")
|
|
52
52
|
when "V", "v"
|
|
53
|
-
handle_hv(cmd, state, context, horizontal:
|
|
53
|
+
handle_hv(cmd, state, context, horizontal: false)
|
|
54
54
|
when "C", "c"
|
|
55
55
|
handle_cubic(cmd, state, smooth, context)
|
|
56
56
|
when "S", "s"
|
|
@@ -177,6 +177,10 @@ module Postsvg
|
|
|
177
177
|
|
|
178
178
|
def self.handle_smooth_quadratic(cmd, state, smooth, context)
|
|
179
179
|
x3, y3 = cmd.args
|
|
180
|
+
if cmd.opcode == "t"
|
|
181
|
+
x3 += state.current_x
|
|
182
|
+
y3 += state.current_y
|
|
183
|
+
end
|
|
180
184
|
qx, qy =
|
|
181
185
|
if smooth.smoothable
|
|
182
186
|
[reflect_x(smooth.control_x, state),
|
|
@@ -190,14 +194,7 @@ module Postsvg
|
|
|
190
194
|
c1y = p0y + ((2.0 / 3.0) * (qy - p0y))
|
|
191
195
|
c2x = x3 + ((2.0 / 3.0) * (qx - x3))
|
|
192
196
|
c2y = y3 + ((2.0 / 3.0) * (qy - y3))
|
|
193
|
-
|
|
194
|
-
state.curve_to(x3, y3)
|
|
195
|
-
else
|
|
196
|
-
state.curve_to_rel(x3 - p0x, y3 - p0y) # treat as rel target
|
|
197
|
-
# Re-anchor to absolute target for emission below.
|
|
198
|
-
state.current_x = p0x + x3
|
|
199
|
-
state.current_y = p0y + y3
|
|
200
|
-
end
|
|
197
|
+
state.curve_to(x3, y3)
|
|
201
198
|
context.emitter.emit(Model::Operators::Path::Curveto.new(
|
|
202
199
|
x1: c1x, y1: c1y, x2: c2x, y2: c2y, x3: state.current_x, y3: state.current_y,
|
|
203
200
|
))
|
|
@@ -211,6 +208,13 @@ module Postsvg
|
|
|
211
208
|
# W3C-endorsed endpoint-to-center conversion.
|
|
212
209
|
def self.handle_arc(cmd, state, context)
|
|
213
210
|
rx, ry, x_axis_rotation, large_arc_flag, sweep_flag, x, y = cmd.args
|
|
211
|
+
if cmd.opcode == "a"
|
|
212
|
+
x += state.current_x
|
|
213
|
+
y += state.current_y
|
|
214
|
+
end
|
|
215
|
+
# SVG omits arcs whose endpoints coincide.
|
|
216
|
+
return if [x, y] == [state.current_x, state.current_y]
|
|
217
|
+
|
|
214
218
|
cx, cy, normalized_rx, normalized_ry, psi, theta1, theta2 =
|
|
215
219
|
Postsvg::Translation::ArcConverter.endpoint_to_center(
|
|
216
220
|
x1: state.current_x, y1: state.current_y,
|
|
@@ -218,27 +222,29 @@ module Postsvg
|
|
|
218
222
|
large_arc: large_arc_flag != 0, sweep: sweep_flag != 0,
|
|
219
223
|
x2: x, y2: y
|
|
220
224
|
)
|
|
221
|
-
#
|
|
222
|
-
|
|
223
|
-
# transforms around the arc.
|
|
225
|
+
# Restore only the CTM: grestore would discard the appended arc.
|
|
226
|
+
arc_class = sweep_flag.zero? ? Model::Operators::Path::Arcn : Model::Operators::Path::Arc
|
|
224
227
|
if x_axis_rotation.abs > 1e-6 || (rx - ry).abs > 1e-6
|
|
225
|
-
context.emitter.emit(Model::Operators::
|
|
228
|
+
context.emitter.emit(Model::Operators::Transformations::Matrix.new)
|
|
229
|
+
context.emitter.emit(Model::Operators::Transformations::Currentmatrix.new)
|
|
226
230
|
context.emitter.emit(Model::Operators::Transformations::Concat.new(matrix: [
|
|
227
231
|
Math.cos(psi), Math.sin(psi), -Math.sin(psi), Math.cos(psi), cx, cy
|
|
228
232
|
]))
|
|
229
233
|
context.emitter.emit(Model::Operators::Transformations::Scale.new(
|
|
230
234
|
sx: normalized_rx, sy: normalized_ry,
|
|
231
235
|
))
|
|
232
|
-
context.emitter.emit(
|
|
236
|
+
context.emitter.emit(arc_class.new(
|
|
233
237
|
x: 0, y: 0, radius: 1.0, angle1: theta1, angle2: theta2,
|
|
234
238
|
))
|
|
235
|
-
context.emitter.emit(Model::Operators::
|
|
239
|
+
context.emitter.emit(Model::Operators::Transformations::Setmatrix.new(
|
|
240
|
+
matrix: Model::Computed.new(operator_keyword: "currentmatrix"),
|
|
241
|
+
))
|
|
236
242
|
else
|
|
237
|
-
context.emitter.emit(
|
|
243
|
+
context.emitter.emit(arc_class.new(
|
|
238
244
|
x: cx, y: cy, radius: normalized_rx, angle1: theta1, angle2: theta2,
|
|
239
245
|
))
|
|
240
246
|
end
|
|
241
|
-
state.
|
|
247
|
+
state.line_to(x, y)
|
|
242
248
|
end
|
|
243
249
|
|
|
244
250
|
# ---- helpers ----
|
|
@@ -9,7 +9,6 @@ module Postsvg
|
|
|
9
9
|
def self.call(element, context)
|
|
10
10
|
context.emitter.emit(Model::Operators::GraphicsState::Gsave.new)
|
|
11
11
|
emit_transform(element, context)
|
|
12
|
-
emit_stroke(element, context)
|
|
13
12
|
context.emitter.emit(Model::Operators::Path::Newpath.new)
|
|
14
13
|
points = element.points
|
|
15
14
|
points.each_slice(2).with_index do |(x, y), idx|
|
|
@@ -21,7 +20,7 @@ module Postsvg
|
|
|
21
20
|
y: y))
|
|
22
21
|
end
|
|
23
22
|
end
|
|
24
|
-
context
|
|
23
|
+
emit_paint(element, context)
|
|
25
24
|
context.emitter.emit(Model::Operators::GraphicsState::Grestore.new)
|
|
26
25
|
xs = points.each_slice(2).map(&:first)
|
|
27
26
|
ys = points.each_slice(2).map(&:last)
|
|
@@ -8,7 +8,9 @@ module Postsvg
|
|
|
8
8
|
# /<font-family> findfont
|
|
9
9
|
# <font-size> scalefont
|
|
10
10
|
# setfont
|
|
11
|
-
# <x> <y>
|
|
11
|
+
# <x> <y> translate
|
|
12
|
+
# 1 -1 scale
|
|
13
|
+
# 0 0 moveto
|
|
12
14
|
# (text) show
|
|
13
15
|
#
|
|
14
16
|
# Real font metrics (kerning, ligatures, glyph substitution) are
|
|
@@ -32,8 +34,14 @@ module Postsvg
|
|
|
32
34
|
context.emitter.emit(Model::Operators::Font::Setfont.new(
|
|
33
35
|
font: Model::Computed.new(operator_keyword: "scalefont"),
|
|
34
36
|
))
|
|
35
|
-
|
|
36
|
-
|
|
37
|
+
# The document transform converts SVG's downward y axis to PS.
|
|
38
|
+
# Restore upward font space around the baseline without undoing
|
|
39
|
+
# the authored text/group transforms or moving the anchor.
|
|
40
|
+
context.emitter.emit(Model::Operators::Transformations::Translate.new(
|
|
41
|
+
tx: element.x, ty: element.y,
|
|
42
|
+
))
|
|
43
|
+
context.emitter.emit(Model::Operators::Transformations::Scale.new(sx: 1, sy: -1))
|
|
44
|
+
context.emitter.emit(Model::Operators::Path::Moveto.new(x: 0, y: 0))
|
|
37
45
|
context.emitter.emit(Model::Operators::Font::Show.new(text: element.content.to_s))
|
|
38
46
|
|
|
39
47
|
context.emitter.emit(Model::Operators::GraphicsState::Grestore.new)
|
|
@@ -72,8 +72,9 @@ module Postsvg
|
|
|
72
72
|
|
|
73
73
|
def compute_viewport_bbox
|
|
74
74
|
if document.viewbox
|
|
75
|
-
|
|
76
|
-
BoundingBox.new(min_x:
|
|
75
|
+
x, y, width, height = document.viewbox
|
|
76
|
+
BoundingBox.new(min_x: x, min_y: y, max_x: x + width,
|
|
77
|
+
max_y: y + height)
|
|
77
78
|
elsif document.width && document.height
|
|
78
79
|
BoundingBox.new(min_x: 0, min_y: 0, max_x: document.width,
|
|
79
80
|
max_y: document.height)
|
|
@@ -86,6 +87,17 @@ module Postsvg
|
|
|
86
87
|
registry.translate(element, context)
|
|
87
88
|
end
|
|
88
89
|
|
|
90
|
+
# SVG's y axis points down; PostScript's points up. Convert once at
|
|
91
|
+
# the document boundary, before any authored element transforms.
|
|
92
|
+
def coordinate_conversion(bbox)
|
|
93
|
+
emitter = RecordEmitter.new
|
|
94
|
+
emitter.emit(Model::Operators::Transformations::Translate.new(
|
|
95
|
+
tx: 0, ty: bbox[3] - bbox[1],
|
|
96
|
+
))
|
|
97
|
+
emitter.emit(Model::Operators::Transformations::Scale.new(sx: 1, sy: -1))
|
|
98
|
+
emitter.records
|
|
99
|
+
end
|
|
100
|
+
|
|
89
101
|
def build_program(emitter, context)
|
|
90
102
|
bbox = context.bounding_box
|
|
91
103
|
header_bbox =
|
|
@@ -99,7 +111,8 @@ module Postsvg
|
|
|
99
111
|
epsf: eps,
|
|
100
112
|
title: "Translated by Postsvg #{::Postsvg::VERSION}",
|
|
101
113
|
)
|
|
102
|
-
Model::Program.new(header: header,
|
|
114
|
+
Model::Program.new(header: header,
|
|
115
|
+
body: coordinate_conversion(header_bbox) + emitter.records)
|
|
103
116
|
end
|
|
104
117
|
end
|
|
105
118
|
end
|
|
@@ -7,6 +7,54 @@ module Postsvg
|
|
|
7
7
|
# surface so handlers don't all need to know how the program is
|
|
8
8
|
# stored.
|
|
9
9
|
class RecordEmitter
|
|
10
|
+
NUMBER_OPERANDS = lambda do |record|
|
|
11
|
+
record.operand_values.map { |value| Model::Literals::Number.new(value) }
|
|
12
|
+
end
|
|
13
|
+
NO_OPERANDS = ->(_record) { [] }
|
|
14
|
+
|
|
15
|
+
OPERAND_BUILDERS = {
|
|
16
|
+
Model::Operators::Color::Setgray => NUMBER_OPERANDS,
|
|
17
|
+
Model::Operators::Color::Setrgbcolor => NUMBER_OPERANDS,
|
|
18
|
+
Model::Operators::GraphicsState::Setlinewidth => NUMBER_OPERANDS,
|
|
19
|
+
Model::Operators::Path::Moveto => NUMBER_OPERANDS,
|
|
20
|
+
Model::Operators::Path::Rmoveto => lambda do |record|
|
|
21
|
+
[Model::Literals::Number.new(record.dx), Model::Literals::Number.new(record.dy)]
|
|
22
|
+
end,
|
|
23
|
+
Model::Operators::Path::Lineto => NUMBER_OPERANDS,
|
|
24
|
+
Model::Operators::Path::Rlineto => NUMBER_OPERANDS,
|
|
25
|
+
Model::Operators::Path::Curveto => NUMBER_OPERANDS,
|
|
26
|
+
Model::Operators::Path::Rcurveto => NUMBER_OPERANDS,
|
|
27
|
+
Model::Operators::Path::Arc => NUMBER_OPERANDS,
|
|
28
|
+
Model::Operators::Path::Arcn => NUMBER_OPERANDS,
|
|
29
|
+
Model::Operators::Transformations::Translate => NUMBER_OPERANDS,
|
|
30
|
+
Model::Operators::Transformations::Scale => NUMBER_OPERANDS,
|
|
31
|
+
Model::Operators::Font::Findfont => lambda do |record|
|
|
32
|
+
# findfont accepts strings as well as names; whitespace must not
|
|
33
|
+
# split a family name into separate PostScript tokens.
|
|
34
|
+
if record.name.match?(/\s/)
|
|
35
|
+
[Model::Literals::StringLiteral.new(record.name)]
|
|
36
|
+
else
|
|
37
|
+
[Model::Literals::Name.new(record.name, literal: true)]
|
|
38
|
+
end
|
|
39
|
+
end,
|
|
40
|
+
Model::Operators::Font::Scalefont => lambda do |record|
|
|
41
|
+
[Model::Literals::Number.new(record.size)]
|
|
42
|
+
end,
|
|
43
|
+
Model::Operators::Font::Show => lambda do |record|
|
|
44
|
+
[Model::Literals::StringLiteral.new(record.text)]
|
|
45
|
+
end,
|
|
46
|
+
Model::Operators::GraphicsState::Setlinecap => lambda do |record|
|
|
47
|
+
[Model::Literals::Number.new(record.cap_code)]
|
|
48
|
+
end,
|
|
49
|
+
Model::Operators::GraphicsState::Setlinejoin => lambda do |record|
|
|
50
|
+
[Model::Literals::Number.new(record.join_code)]
|
|
51
|
+
end,
|
|
52
|
+
Model::Operators::Transformations::Concat => lambda do |record|
|
|
53
|
+
elements = record.matrix.map { |value| Model::Literals::Number.new(value) }
|
|
54
|
+
[Model::Literals::ArrayLiteral.new(elements)]
|
|
55
|
+
end,
|
|
56
|
+
}.freeze
|
|
57
|
+
|
|
10
58
|
attr_reader :records
|
|
11
59
|
|
|
12
60
|
def initialize
|
|
@@ -19,12 +67,7 @@ module Postsvg
|
|
|
19
67
|
# and expects the operands as separate literal statements
|
|
20
68
|
# ahead of the keyword (the shape the PS parse path produces).
|
|
21
69
|
# Splice them in so the record stream round-trips.
|
|
22
|
-
|
|
23
|
-
record.class.public_method_defined?(:operand_values)
|
|
24
|
-
record.operand_values.each do |v|
|
|
25
|
-
@records << Model::Literals::Number.new(v)
|
|
26
|
-
end
|
|
27
|
-
end
|
|
70
|
+
@records.concat(OPERAND_BUILDERS.fetch(record.class, NO_OPERANDS).call(record))
|
|
28
71
|
@records << record
|
|
29
72
|
self
|
|
30
73
|
end
|
data/lib/postsvg/version.rb
CHANGED
|
@@ -7,15 +7,17 @@ module Postsvg
|
|
|
7
7
|
# dash attribute setters.
|
|
8
8
|
module GraphicsState
|
|
9
9
|
def visit_gsave(_op, _ctx)
|
|
10
|
+
@saved_paths << @path.dup
|
|
10
11
|
@graphics.gsave
|
|
11
12
|
end
|
|
12
13
|
|
|
13
14
|
def visit_grestore(_op, _ctx)
|
|
15
|
+
@path = @saved_paths.pop if @graphics.depth > 1 && !@saved_paths.empty?
|
|
14
16
|
@graphics.grestore
|
|
15
17
|
end
|
|
16
18
|
|
|
17
19
|
def visit_grestoreall(_op, _ctx)
|
|
18
|
-
|
|
20
|
+
visit_grestore(nil, nil) while @graphics.depth > 1
|
|
19
21
|
end
|
|
20
22
|
|
|
21
23
|
def visit_setlinewidth(op, _ctx)
|
|
@@ -25,7 +25,7 @@ module Postsvg
|
|
|
25
25
|
def visit_clip(_op, _ctx)
|
|
26
26
|
return if @path.empty?
|
|
27
27
|
|
|
28
|
-
path_d = @path.to_path_d
|
|
28
|
+
path_d = @path.to_path_d(@graphics.current.ctm)
|
|
29
29
|
clip_id = @builder.register_clip_path(path_d)
|
|
30
30
|
@graphics.update(clip_paths: @graphics.current.clip_paths + [clip_id])
|
|
31
31
|
@path.reset
|
|
@@ -43,7 +43,7 @@ module Postsvg
|
|
|
43
43
|
def flush_path(mode)
|
|
44
44
|
return if @path.empty?
|
|
45
45
|
|
|
46
|
-
d = @path.to_path_d
|
|
46
|
+
d = @path.to_path_d(@graphics.current.ctm)
|
|
47
47
|
ctx = @graphics.current
|
|
48
48
|
transform_str = build_transform_string(ctx)
|
|
49
49
|
clip_id = ctx.clip_paths.last
|
|
@@ -5,20 +5,23 @@ module Postsvg
|
|
|
5
5
|
class PsVisitor
|
|
6
6
|
# Path construction operator handlers. Each appends a command to
|
|
7
7
|
# +@path+ (PathState). CTM-aware: when the current CTM is not
|
|
8
|
-
# identity, the renderer
|
|
9
|
-
#
|
|
10
|
-
#
|
|
8
|
+
# identity, the renderer wraps painted paths in a <g transform=>.
|
|
9
|
+
# Arcs retain their construction CTM and are mapped into the paint CTM
|
|
10
|
+
# at emission, whether or not the temporary transform was restored.
|
|
11
|
+
# Other mid-path CTM changes retain the existing paint-time behavior.
|
|
11
12
|
module Path
|
|
12
13
|
def visit_newpath(_op, _ctx)
|
|
13
14
|
@path.reset
|
|
14
15
|
end
|
|
15
16
|
|
|
16
17
|
def visit_moveto(op, _ctx)
|
|
18
|
+
@path.matrix ||= @graphics.current.ctm
|
|
17
19
|
@path.move_to(op.x, op.y)
|
|
18
20
|
@graphics.update(last_text_position: { x: op.x, y: op.y })
|
|
19
21
|
end
|
|
20
22
|
|
|
21
23
|
def visit_rmoveto(op, _ctx)
|
|
24
|
+
@path.matrix ||= @graphics.current.ctm
|
|
22
25
|
@path.move_to_rel(op.dx, op.dy)
|
|
23
26
|
pos = @graphics.current.last_text_position
|
|
24
27
|
base_x = pos ? pos[:x] : 0
|
|
@@ -66,50 +69,61 @@ module Postsvg
|
|
|
66
69
|
# are parametrised by center, radius, angle range. SVG arcs
|
|
67
70
|
# are parametrised by endpoint, radii, large-arc/sweep flags.
|
|
68
71
|
def append_arc(cx, cy, radius, angle1, angle2, sweep:)
|
|
72
|
+
consume_numeric_operands(cx, cy, radius, angle1, angle2)
|
|
69
73
|
r = radius.abs
|
|
70
74
|
return if r.zero?
|
|
71
75
|
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
if (@path.empty? || last_command_starts_with?("M")) && @path.empty?
|
|
82
|
-
@path.move_to(start_x, start_y)
|
|
76
|
+
construction_matrix = @graphics.current.ctm
|
|
77
|
+
@path.matrix ||= construction_matrix
|
|
78
|
+
point_matrix = @path.matrix.invert.multiply(construction_matrix)
|
|
79
|
+
end_x, end_y = arc_point(point_matrix, cx, cy, r, angle2)
|
|
80
|
+
starts_path = @path.empty?
|
|
81
|
+
command = lambda do |paint_matrix|
|
|
82
|
+
matrix = paint_matrix.invert.multiply(construction_matrix)
|
|
83
|
+
render_arc(cx, cy, r, angle1, angle2, sweep: sweep,
|
|
84
|
+
matrix: matrix, starts_path: starts_path)
|
|
83
85
|
end
|
|
86
|
+
@path.append_deferred(command, end_x, end_y)
|
|
87
|
+
end
|
|
84
88
|
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
end
|
|
89
|
+
def render_arc(cx, cy, radius, angle1, angle2, sweep:, matrix:, starts_path:)
|
|
90
|
+
path = PathState.new
|
|
91
|
+
start_x, start_y = arc_point(matrix, cx, cy, radius, angle1)
|
|
92
|
+
end_x, end_y = arc_point(matrix, cx, cy, radius, angle2)
|
|
93
|
+
path.move_to(start_x, start_y) if starts_path
|
|
94
|
+
delta = sweep ? (angle2 - angle1) % 360 : (angle1 - angle2) % 360
|
|
95
|
+
rx, ry, rotation = arc_radii(matrix, radius)
|
|
96
|
+
reflected = ((matrix.a * matrix.d) - (matrix.b * matrix.c)).negative?
|
|
97
|
+
svg_sweep = sweep != reflected
|
|
95
98
|
if delta.abs < 1e-6
|
|
96
|
-
# Full
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
@path.arc_to(r, r, 0, 0, svg_sweep, mid_x, mid_y)
|
|
102
|
-
@path.arc_to(r, r, 0, 0, svg_sweep, start_x, start_y)
|
|
103
|
-
@path.close
|
|
99
|
+
# Full circles need two arcs; retain the corpus's large-arc spelling.
|
|
100
|
+
mid_x, mid_y = arc_point(matrix, cx, cy, radius, angle1 + 180)
|
|
101
|
+
path.arc_to(rx, ry, rotation, true, svg_sweep, mid_x, mid_y)
|
|
102
|
+
path.arc_to(rx, ry, rotation, true, svg_sweep, start_x, start_y)
|
|
103
|
+
path.close
|
|
104
104
|
else
|
|
105
|
-
|
|
105
|
+
path.arc_to(rx, ry, rotation, delta > 180, svg_sweep, end_x, end_y)
|
|
106
106
|
end
|
|
107
|
+
path.to_path_d
|
|
107
108
|
end
|
|
108
109
|
|
|
109
|
-
def
|
|
110
|
-
|
|
110
|
+
def arc_point(matrix, cx, cy, radius, angle)
|
|
111
|
+
radians = angle * Math::PI / 180.0
|
|
112
|
+
matrix.apply_point(cx + (radius * Math.cos(radians)),
|
|
113
|
+
cy + (radius * Math.sin(radians))).values_at(:x, :y)
|
|
114
|
+
end
|
|
111
115
|
|
|
112
|
-
|
|
116
|
+
# Singular values give the ellipse radii even when its frame is rotated
|
|
117
|
+
# or sheared. The principal eigenvector gives its SVG axis rotation.
|
|
118
|
+
def arc_radii(matrix, radius)
|
|
119
|
+
xx = (matrix.a**2) + (matrix.c**2)
|
|
120
|
+
xy = (matrix.a * matrix.b) + (matrix.c * matrix.d)
|
|
121
|
+
yy = (matrix.b**2) + (matrix.d**2)
|
|
122
|
+
root = Math.hypot(xx - yy, 2 * xy)
|
|
123
|
+
rx = radius * Math.sqrt([(xx + yy + root) / 2, 0].max)
|
|
124
|
+
ry = radius * Math.sqrt([(xx + yy - root) / 2, 0].max)
|
|
125
|
+
rotation = root < 1e-12 ? 0.0 : Math.atan2(2 * xy, xx - yy) * 90 / Math::PI
|
|
126
|
+
[rx, ry, rotation]
|
|
113
127
|
end
|
|
114
128
|
end
|
|
115
129
|
end
|
|
@@ -6,12 +6,19 @@ module Postsvg
|
|
|
6
6
|
# Coordinate transformation operator handlers. Each returns a
|
|
7
7
|
# new CTM via Matrix composition and updates the GraphicsContext.
|
|
8
8
|
module Transformations
|
|
9
|
+
# A matrix this visitor produced (`matrix`, `currentmatrix`). The
|
|
10
|
+
# runtime stack also holds values nothing popped (`def` operands,
|
|
11
|
+
# unresolved names, path operands), so only these are trusted as a
|
|
12
|
+
# matrix the parser could not see.
|
|
13
|
+
class StackMatrix < Array; end
|
|
14
|
+
|
|
9
15
|
def visit_translate(op, _ctx)
|
|
10
16
|
new_ctm = @graphics.current.ctm.translate(op.tx.to_f, op.ty.to_f)
|
|
11
17
|
@graphics.update(ctm: new_ctm)
|
|
12
18
|
end
|
|
13
19
|
|
|
14
20
|
def visit_scale(op, _ctx)
|
|
21
|
+
consume_numeric_operands(op.sx, op.sy)
|
|
15
22
|
new_ctm = @graphics.current.ctm.scale(op.sx.to_f, op.sy.to_f)
|
|
16
23
|
@graphics.update(ctm: new_ctm)
|
|
17
24
|
end
|
|
@@ -22,7 +29,7 @@ module Postsvg
|
|
|
22
29
|
end
|
|
23
30
|
|
|
24
31
|
def visit_concat(op, _ctx)
|
|
25
|
-
matrix = matrix_from_operand(op
|
|
32
|
+
matrix = matrix_from_operand(matrix_operand(op))
|
|
26
33
|
return unless matrix
|
|
27
34
|
|
|
28
35
|
new_ctm = @graphics.current.ctm.multiply(matrix)
|
|
@@ -30,16 +37,17 @@ module Postsvg
|
|
|
30
37
|
end
|
|
31
38
|
|
|
32
39
|
def visit_matrix(_op, _ctx)
|
|
33
|
-
@stack << [1, 0, 0, 1, 0, 0]
|
|
40
|
+
@stack << StackMatrix[1, 0, 0, 1, 0, 0]
|
|
34
41
|
end
|
|
35
42
|
|
|
36
43
|
def visit_currentmatrix(_op, _ctx)
|
|
44
|
+
@stack.pop if @stack.last.is_a?(StackMatrix)
|
|
37
45
|
ctm = @graphics.current.ctm
|
|
38
|
-
@stack << [ctm.a, ctm.b, ctm.c, ctm.d, ctm.e, ctm.f]
|
|
46
|
+
@stack << StackMatrix[ctm.a, ctm.b, ctm.c, ctm.d, ctm.e, ctm.f]
|
|
39
47
|
end
|
|
40
48
|
|
|
41
49
|
def visit_setmatrix(op, _ctx)
|
|
42
|
-
matrix = matrix_from_operand(op
|
|
50
|
+
matrix = matrix_from_operand(matrix_operand(op))
|
|
43
51
|
return unless matrix
|
|
44
52
|
|
|
45
53
|
@graphics.update(ctm: matrix)
|
|
@@ -85,16 +93,46 @@ module Postsvg
|
|
|
85
93
|
|
|
86
94
|
private
|
|
87
95
|
|
|
96
|
+
# Parsed literal operands remain on the visitor stack. Consume only
|
|
97
|
+
# an exact numeric suffix, leaving directly constructed operators alone.
|
|
98
|
+
def consume_numeric_operands(*values)
|
|
99
|
+
@stack.pop(values.length) if @stack.last(values.length) == values
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
# A literal matrix was also pushed by visit_array; consume that copy
|
|
103
|
+
# so a later operator does not apply it again. A computed matrix is
|
|
104
|
+
# taken from the stack only when this visitor produced it.
|
|
105
|
+
def matrix_operand(op)
|
|
106
|
+
unless op.matrix.is_a?(Model::Computed)
|
|
107
|
+
@stack.pop if literal_on_stack?(op.matrix)
|
|
108
|
+
return op.matrix
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
@stack.pop if @stack.last.is_a?(StackMatrix)
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
def literal_on_stack?(matrix)
|
|
115
|
+
matrix.is_a?(Model::Literals::ArrayLiteral) && @stack.last.equal?(matrix.elements)
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
# Arrays on the runtime stack hold either plain numbers or parsed
|
|
119
|
+
# Number literals. Anything other than six numbers is not a matrix.
|
|
88
120
|
def matrix_from_operand(operand)
|
|
89
121
|
case operand
|
|
90
122
|
when Matrix then operand
|
|
123
|
+
when Model::Literals::ArrayLiteral then matrix_from_operand(operand.elements)
|
|
91
124
|
when Array
|
|
92
|
-
|
|
125
|
+
numbers = operand.map { |element| matrix_number(element) }
|
|
126
|
+
return nil unless numbers.length == 6 && numbers.all?
|
|
93
127
|
|
|
94
|
-
Matrix.new(a:
|
|
95
|
-
d:
|
|
128
|
+
Matrix.new(a: numbers[0], b: numbers[1], c: numbers[2],
|
|
129
|
+
d: numbers[3], e: numbers[4], f: numbers[5])
|
|
96
130
|
end
|
|
97
131
|
end
|
|
132
|
+
|
|
133
|
+
def matrix_number(element)
|
|
134
|
+
numeric_value(element) if element.is_a?(Numeric) || element.is_a?(Model::Literals::Number)
|
|
135
|
+
end
|
|
98
136
|
end
|
|
99
137
|
end
|
|
100
138
|
end
|
|
@@ -36,6 +36,7 @@ module Postsvg
|
|
|
36
36
|
@graphics = graphics
|
|
37
37
|
@stack = [] # operand stack for execution semantics
|
|
38
38
|
@path = PathState.new
|
|
39
|
+
@saved_paths = []
|
|
39
40
|
@dict_stack = [{}] # runtime dict stack (mirrors PLRM dict stack)
|
|
40
41
|
@recursion_depth = 0
|
|
41
42
|
end
|
|
@@ -116,6 +117,7 @@ module Postsvg
|
|
|
116
117
|
# geometry bookkeeping.
|
|
117
118
|
class PathState
|
|
118
119
|
attr_reader :commands, :current_x, :current_y
|
|
120
|
+
attr_accessor :matrix
|
|
119
121
|
|
|
120
122
|
def initialize
|
|
121
123
|
@commands = []
|
|
@@ -127,6 +129,11 @@ module Postsvg
|
|
|
127
129
|
@commands.empty?
|
|
128
130
|
end
|
|
129
131
|
|
|
132
|
+
def initialize_copy(other)
|
|
133
|
+
super
|
|
134
|
+
@commands = other.commands.map(&:dup)
|
|
135
|
+
end
|
|
136
|
+
|
|
130
137
|
def move_to(x, y)
|
|
131
138
|
@commands << "M #{fmt(x)} #{fmt(y)}"
|
|
132
139
|
@current_x = x.to_f
|
|
@@ -170,18 +177,27 @@ module Postsvg
|
|
|
170
177
|
@current_y = y.to_f
|
|
171
178
|
end
|
|
172
179
|
|
|
180
|
+
def append_deferred(command, x, y)
|
|
181
|
+
@commands << command
|
|
182
|
+
@current_x = x
|
|
183
|
+
@current_y = y
|
|
184
|
+
end
|
|
185
|
+
|
|
173
186
|
def close
|
|
174
187
|
@commands << "Z"
|
|
175
188
|
end
|
|
176
189
|
|
|
177
190
|
def reset
|
|
191
|
+
@matrix = nil
|
|
178
192
|
@commands = []
|
|
179
193
|
@current_x = 0.0
|
|
180
194
|
@current_y = 0.0
|
|
181
195
|
end
|
|
182
196
|
|
|
183
|
-
def to_path_d
|
|
184
|
-
@commands.
|
|
197
|
+
def to_path_d(paint_matrix = @matrix || Matrix.new)
|
|
198
|
+
@commands.map do |command|
|
|
199
|
+
command.is_a?(Proc) ? command.call(paint_matrix) : command
|
|
200
|
+
end.join(" ")
|
|
185
201
|
end
|
|
186
202
|
|
|
187
203
|
def fmt(value)
|
metadata
CHANGED
|
@@ -1,29 +1,29 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: postsvg
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.2.
|
|
4
|
+
version: 0.2.6
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Ribose Inc.
|
|
8
8
|
autorequire:
|
|
9
9
|
bindir: exe
|
|
10
10
|
cert_chain: []
|
|
11
|
-
date: 2026-
|
|
11
|
+
date: 2026-11-02 00:00:00.000000000 Z
|
|
12
12
|
dependencies:
|
|
13
13
|
- !ruby/object:Gem::Dependency
|
|
14
14
|
name: leptris
|
|
15
15
|
requirement: !ruby/object:Gem::Requirement
|
|
16
16
|
requirements:
|
|
17
|
-
- - "
|
|
17
|
+
- - ">="
|
|
18
18
|
- !ruby/object:Gem::Version
|
|
19
|
-
version: 1.9.
|
|
19
|
+
version: 1.9.399.0
|
|
20
20
|
type: :runtime
|
|
21
21
|
prerelease: false
|
|
22
22
|
version_requirements: !ruby/object:Gem::Requirement
|
|
23
23
|
requirements:
|
|
24
|
-
- - "
|
|
24
|
+
- - ">="
|
|
25
25
|
- !ruby/object:Gem::Version
|
|
26
|
-
version: 1.9.
|
|
26
|
+
version: 1.9.399.0
|
|
27
27
|
- !ruby/object:Gem::Dependency
|
|
28
28
|
name: parslet
|
|
29
29
|
requirement: !ruby/object:Gem::Requirement
|
|
@@ -186,6 +186,7 @@ files:
|
|
|
186
186
|
- docs/troubleshooting.adoc
|
|
187
187
|
- docs/validation.adoc
|
|
188
188
|
- exe/postsvg
|
|
189
|
+
- gemfiles/native-parity/Gemfile
|
|
189
190
|
- lib/postsvg.rb
|
|
190
191
|
- lib/postsvg/cli.rb
|
|
191
192
|
- lib/postsvg/colors.rb
|