postsvg 0.1.0 → 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.
Files changed (201) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +105 -0
  3. data/CLAUDE.md +173 -0
  4. data/Gemfile +2 -3
  5. data/README.adoc +456 -179
  6. data/Rakefile +100 -0
  7. data/TODO.roadmap/00-architecture.md +139 -0
  8. data/TODO.roadmap/01-autoload-migration.md +39 -0
  9. data/TODO.roadmap/02-isolate-dormant-code.md +51 -0
  10. data/TODO.roadmap/03-domain-model.md +66 -0
  11. data/TODO.roadmap/04-lexer.md +40 -0
  12. data/TODO.roadmap/05-parser.md +43 -0
  13. data/TODO.roadmap/06-graphics-state.md +45 -0
  14. data/TODO.roadmap/07-matrix-and-color.md +37 -0
  15. data/TODO.roadmap/08-svg-builder.md +51 -0
  16. data/TODO.roadmap/09-renderer.md +52 -0
  17. data/TODO.roadmap/10-visitor.md +58 -0
  18. data/TODO.roadmap/11-operator-coverage.md +103 -0
  19. data/TODO.roadmap/12-svg-domain.md +62 -0
  20. data/TODO.roadmap/13-translation-handlers.md +69 -0
  21. data/TODO.roadmap/14-ps-serializer.md +49 -0
  22. data/TODO.roadmap/15-cli-and-public-api.md +47 -0
  23. data/TODO.roadmap/16-specs.md +84 -0
  24. data/TODO.roadmap/17-docs-sync.md +47 -0
  25. data/TODO.roadmap/18-performance-and-determinism.md +47 -0
  26. data/TODO.roadmap/19-error-model.md +45 -0
  27. data/TODO.roadmap/20-font-and-text.md +46 -0
  28. data/TODO.roadmap/21-images.md +45 -0
  29. data/TODO.roadmap/22-forms-and-resources.md +25 -0
  30. data/TODO.roadmap/23-level2-level3.md +52 -0
  31. data/TODO.roadmap/24-ci-and-release.md +42 -0
  32. data/TODO.roadmap/README.md +77 -0
  33. data/docs/.gitignore +29 -0
  34. data/docs/CHANGELOG.md +114 -0
  35. data/docs/COMPLETE_DOCUMENTATION_STATUS.md +376 -0
  36. data/docs/DEPLOYMENT.md +456 -0
  37. data/docs/DEPLOYMENT_INSTRUCTIONS.md +229 -0
  38. data/docs/DOCUMENTATION_PLAN.md +425 -0
  39. data/docs/FINAL_SUMMARY.md +657 -0
  40. data/docs/Gemfile +15 -0
  41. data/docs/README.md +327 -0
  42. data/docs/_config.yml +99 -0
  43. data/docs/advanced-topics.adoc +370 -0
  44. data/docs/api-reference/colors.adoc +705 -0
  45. data/docs/api-reference/converter.adoc +699 -0
  46. data/docs/api-reference/execution-context.adoc +1210 -0
  47. data/docs/api-reference/graphics-state.adoc +1070 -0
  48. data/docs/api-reference/interpreter.adoc +810 -0
  49. data/docs/api-reference/matrix.adoc +1179 -0
  50. data/docs/api-reference/path-builder.adoc +1284 -0
  51. data/docs/api-reference/postsvg-module.adoc +388 -0
  52. data/docs/api-reference/svg-generator.adoc +891 -0
  53. data/docs/api-reference/tokenizer.adoc +925 -0
  54. data/docs/api-reference.adoc +221 -0
  55. data/docs/architecture/command-registry.adoc +1191 -0
  56. data/docs/architecture/conversion-pipeline.adoc +746 -0
  57. data/docs/architecture/design-decisions.adoc +999 -0
  58. data/docs/architecture/generator-stage.adoc +1115 -0
  59. data/docs/architecture/graphics-state-model.adoc +1089 -0
  60. data/docs/architecture/interpreter-stage.adoc +1125 -0
  61. data/docs/architecture/parser-stage.adoc +1051 -0
  62. data/docs/architecture.adoc +354 -0
  63. data/docs/cli-reference/batch-command.adoc +616 -0
  64. data/docs/cli-reference/check-command.adoc +677 -0
  65. data/docs/cli-reference/cli-options.adoc +802 -0
  66. data/docs/cli-reference/convert-command.adoc +462 -0
  67. data/docs/cli-reference/version-command.adoc +296 -0
  68. data/docs/cli-reference.adoc +317 -0
  69. data/docs/concepts/conversion-pipeline.adoc +903 -0
  70. data/docs/concepts/coordinate-systems.adoc +836 -0
  71. data/docs/concepts/graphics-state.adoc +861 -0
  72. data/docs/concepts/path-operations.adoc +1076 -0
  73. data/docs/concepts/postscript-language.adoc +859 -0
  74. data/docs/concepts/svg-generation.adoc +937 -0
  75. data/docs/concepts.adoc +198 -0
  76. data/docs/contributing.adoc +443 -0
  77. data/docs/development.adoc +420 -0
  78. data/docs/faq.adoc +493 -0
  79. data/docs/getting-started/basic-usage.adoc +538 -0
  80. data/docs/getting-started/common-workflows.adoc +577 -0
  81. data/docs/getting-started/first-conversion.adoc +492 -0
  82. data/docs/getting-started/installation.adoc +534 -0
  83. data/docs/getting-started.adoc +94 -0
  84. data/docs/index.adoc +248 -0
  85. data/docs/optimization.adoc +196 -0
  86. data/docs/ps2svg_compatibility.adoc +149 -0
  87. data/docs/quick-reference.adoc +453 -0
  88. data/docs/sitemap.adoc +337 -0
  89. data/docs/troubleshooting.adoc +486 -0
  90. data/docs/validation.adoc +772 -0
  91. data/exe/postsvg +1 -0
  92. data/lib/postsvg/cli.rb +104 -57
  93. data/lib/postsvg/color.rb +132 -0
  94. data/lib/postsvg/errors.rb +68 -3
  95. data/lib/postsvg/format_number.rb +22 -0
  96. data/lib/postsvg/graphics_context.rb +80 -0
  97. data/lib/postsvg/graphics_stack.rb +43 -0
  98. data/lib/postsvg/model/literals/array.rb +41 -0
  99. data/lib/postsvg/model/literals/dictionary.rb +34 -0
  100. data/lib/postsvg/model/literals/hex.rb +37 -0
  101. data/lib/postsvg/model/literals/name.rb +40 -0
  102. data/lib/postsvg/model/literals/number.rb +36 -0
  103. data/lib/postsvg/model/literals/procedure.rb +41 -0
  104. data/lib/postsvg/model/literals/string.rb +30 -0
  105. data/lib/postsvg/model/literals.rb +19 -0
  106. data/lib/postsvg/model/operator.rb +58 -0
  107. data/lib/postsvg/model/operators/arithmetic.rb +264 -0
  108. data/lib/postsvg/model/operators/boolean.rb +182 -0
  109. data/lib/postsvg/model/operators/color.rb +74 -0
  110. data/lib/postsvg/model/operators/container.rb +186 -0
  111. data/lib/postsvg/model/operators/control_flow.rb +119 -0
  112. data/lib/postsvg/model/operators/device.rb +21 -0
  113. data/lib/postsvg/model/operators/dictionary.rb +118 -0
  114. data/lib/postsvg/model/operators/font.rb +121 -0
  115. data/lib/postsvg/model/operators/graphics_state.rb +84 -0
  116. data/lib/postsvg/model/operators/painting.rb +29 -0
  117. data/lib/postsvg/model/operators/path.rb +169 -0
  118. data/lib/postsvg/model/operators/stack.rb +72 -0
  119. data/lib/postsvg/model/operators/transformations.rb +103 -0
  120. data/lib/postsvg/model/operators.rb +89 -0
  121. data/lib/postsvg/model/program.rb +68 -0
  122. data/lib/postsvg/model/token.rb +43 -0
  123. data/lib/postsvg/model.rb +17 -0
  124. data/lib/postsvg/options.rb +29 -0
  125. data/lib/postsvg/renderer.rb +85 -0
  126. data/lib/postsvg/serializer.rb +325 -0
  127. data/lib/postsvg/source/ast_builder.rb +308 -0
  128. data/lib/postsvg/source/lexer.rb +322 -0
  129. data/lib/postsvg/source/operand_stack.rb +55 -0
  130. data/lib/postsvg/source.rb +21 -0
  131. data/lib/postsvg/svg/attribute_parser.rb +45 -0
  132. data/lib/postsvg/svg/clip_path_registry.rb +44 -0
  133. data/lib/postsvg/svg/document.rb +22 -0
  134. data/lib/postsvg/svg/element.rb +84 -0
  135. data/lib/postsvg/svg/elements/circle.rb +36 -0
  136. data/lib/postsvg/svg/elements/clip_path.rb +26 -0
  137. data/lib/postsvg/svg/elements/defs.rb +24 -0
  138. data/lib/postsvg/svg/elements/ellipse.rb +38 -0
  139. data/lib/postsvg/svg/elements/group.rb +37 -0
  140. data/lib/postsvg/svg/elements/image.rb +35 -0
  141. data/lib/postsvg/svg/elements/line.rb +36 -0
  142. data/lib/postsvg/svg/elements/path.rb +32 -0
  143. data/lib/postsvg/svg/elements/polygon.rb +12 -0
  144. data/lib/postsvg/svg/elements/polyline.rb +32 -0
  145. data/lib/postsvg/svg/elements/rect.rb +44 -0
  146. data/lib/postsvg/svg/elements/svg.rb +39 -0
  147. data/lib/postsvg/svg/elements/text.rb +42 -0
  148. data/lib/postsvg/svg/elements.rb +31 -0
  149. data/lib/postsvg/svg/paint.rb +34 -0
  150. data/lib/postsvg/svg/parser.rb +39 -0
  151. data/lib/postsvg/svg/path_data/command.rb +27 -0
  152. data/lib/postsvg/svg/path_data/parser.rb +82 -0
  153. data/lib/postsvg/svg/path_data.rb +17 -0
  154. data/lib/postsvg/svg/stroke.rb +31 -0
  155. data/lib/postsvg/svg/transform_list.rb +59 -0
  156. data/lib/postsvg/svg.rb +22 -0
  157. data/lib/postsvg/svg_builder.rb +249 -0
  158. data/lib/postsvg/translation/arc_converter.rb +86 -0
  159. data/lib/postsvg/translation/bounding_box.rb +59 -0
  160. data/lib/postsvg/translation/context.rb +34 -0
  161. data/lib/postsvg/translation/handler_registry.rb +38 -0
  162. data/lib/postsvg/translation/handlers/circle_handler.rb +28 -0
  163. data/lib/postsvg/translation/handlers/clip_path_handler.rb +13 -0
  164. data/lib/postsvg/translation/handlers/defs_handler.rb +15 -0
  165. data/lib/postsvg/translation/handlers/ellipse_handler.rb +31 -0
  166. data/lib/postsvg/translation/handlers/group_handler.rb +21 -0
  167. data/lib/postsvg/translation/handlers/image_handler.rb +20 -0
  168. data/lib/postsvg/translation/handlers/line_handler.rb +23 -0
  169. data/lib/postsvg/translation/handlers/open_handler.rb +18 -0
  170. data/lib/postsvg/translation/handlers/path_handler.rb +356 -0
  171. data/lib/postsvg/translation/handlers/polygon_handler.rb +33 -0
  172. data/lib/postsvg/translation/handlers/polyline_handler.rb +31 -0
  173. data/lib/postsvg/translation/handlers/rect_handler.rb +27 -0
  174. data/lib/postsvg/translation/handlers/shared.rb +110 -0
  175. data/lib/postsvg/translation/handlers/svg_handler.rb +25 -0
  176. data/lib/postsvg/translation/handlers/text_handler.rb +56 -0
  177. data/lib/postsvg/translation/handlers.rb +25 -0
  178. data/lib/postsvg/translation/ps_renderer.rb +105 -0
  179. data/lib/postsvg/translation/record_emitter.rb +35 -0
  180. data/lib/postsvg/translation.rb +17 -0
  181. data/lib/postsvg/version.rb +1 -1
  182. data/lib/postsvg/visitors/ps_visitor/arithmetic.rb +125 -0
  183. data/lib/postsvg/visitors/ps_visitor/boolean.rb +105 -0
  184. data/lib/postsvg/visitors/ps_visitor/color.rb +53 -0
  185. data/lib/postsvg/visitors/ps_visitor/common.rb +66 -0
  186. data/lib/postsvg/visitors/ps_visitor/container.rb +164 -0
  187. data/lib/postsvg/visitors/ps_visitor/control_flow.rb +110 -0
  188. data/lib/postsvg/visitors/ps_visitor/device.rb +20 -0
  189. data/lib/postsvg/visitors/ps_visitor/dictionary.rb +89 -0
  190. data/lib/postsvg/visitors/ps_visitor/font.rb +93 -0
  191. data/lib/postsvg/visitors/ps_visitor/graphics_state.rb +55 -0
  192. data/lib/postsvg/visitors/ps_visitor/painting.rb +90 -0
  193. data/lib/postsvg/visitors/ps_visitor/path.rb +112 -0
  194. data/lib/postsvg/visitors/ps_visitor/stack.rb +47 -0
  195. data/lib/postsvg/visitors/ps_visitor/transformations.rb +101 -0
  196. data/lib/postsvg/visitors/ps_visitor.rb +208 -0
  197. data/lib/postsvg/visitors.rb +9 -0
  198. data/lib/postsvg.rb +93 -59
  199. data/lychee.toml +86 -0
  200. metadata +216 -11
  201. data/postsvg.gemspec +0 -38
data/Rakefile CHANGED
@@ -8,3 +8,103 @@ RSpec::Core::RakeTask.new(:spec)
8
8
  RuboCop::RakeTask.new
9
9
 
10
10
  task default: %i[spec rubocop]
11
+
12
+ # Link checking tasks
13
+ namespace :check do
14
+ desc "Check internal documentation links only (recommended for CI)"
15
+ task :links do
16
+ puts "Checking internal documentation links..."
17
+ puts "(External URLs are skipped to avoid anti-bot measures)"
18
+ puts
19
+
20
+ # Check if lychee is installed
21
+ unless system("which lychee > /dev/null 2>&1")
22
+ puts "\n❌ Error: lychee is not installed."
23
+ puts "\nInstall lychee using one of these methods:"
24
+ puts " - cargo install lychee (requires Rust)"
25
+ puts " - brew install lychee (macOS)"
26
+ puts " - See https://lychee.cli.rs/ for more options"
27
+ exit 1
28
+ end
29
+
30
+ # Run lychee with --offline to check only internal links
31
+ success = system(
32
+ "lychee",
33
+ "--config", "lychee.toml",
34
+ "--offline",
35
+ "--format", "detailed",
36
+ "docs/**/*.adoc",
37
+ "README.adoc"
38
+ )
39
+
40
+ if success
41
+ puts "\n✅ All internal documentation links are valid!"
42
+ else
43
+ puts "\n❌ Broken internal links found. See output above for details."
44
+ exit 1
45
+ end
46
+ end
47
+
48
+ desc "Check all links including external URLs (may have false positives)"
49
+ task :links_all do
50
+ puts "Checking all documentation links (internal + external)..."
51
+ puts "⚠️ Note: External URLs may fail due to rate limiting or anti-bot measures"
52
+ puts
53
+
54
+ unless system("which lychee > /dev/null 2>&1")
55
+ puts "\n❌ Error: lychee is not installed."
56
+ puts "\nInstall lychee using one of these methods:"
57
+ puts " - cargo install lychee (requires Rust)"
58
+ puts " - brew install lychee (macOS)"
59
+ puts " - See https://lychee.cli.rs/ for more options"
60
+ exit 1
61
+ end
62
+
63
+ success = system(
64
+ "lychee",
65
+ "--config", "lychee.toml",
66
+ "--format", "detailed",
67
+ "docs/**/*.adoc",
68
+ "README.adoc"
69
+ )
70
+
71
+ if success
72
+ puts "\n✅ All links are valid!"
73
+ else
74
+ puts "\n❌ Some links failed validation."
75
+ puts "Note: External URL failures may be due to anti-bot measures, not actual broken links."
76
+ exit 1
77
+ end
78
+ end
79
+
80
+ desc "Check documentation links (verbose output)"
81
+ task :links_verbose do
82
+ puts "Checking internal documentation links (verbose)..."
83
+
84
+ unless system("which lychee > /dev/null 2>&1")
85
+ puts "\n❌ Error: lychee is not installed."
86
+ puts "\nInstall lychee using one of these methods:"
87
+ puts " - cargo install lychee (requires Rust)"
88
+ puts " - brew install lychee (macOS)"
89
+ puts " - See https://lychee.cli.rs/ for more options"
90
+ exit 1
91
+ end
92
+
93
+ success = system(
94
+ "lychee",
95
+ "--config", "lychee.toml",
96
+ "--offline",
97
+ "--format", "detailed",
98
+ "--verbose",
99
+ "docs/**/*.adoc",
100
+ "README.adoc"
101
+ )
102
+
103
+ if success
104
+ puts "\n✅ All internal links are valid!"
105
+ else
106
+ puts "\n❌ Broken links found. See output above for details."
107
+ exit 1
108
+ end
109
+ end
110
+ end
@@ -0,0 +1,139 @@
1
+ # 00 — Architecture
2
+
3
+ ## Status: [x] (this is the design doc; it is the contract for all later files)
4
+
5
+ ## Problem
6
+
7
+ Postsvg 0.1.0 ships two parallel pipelines:
8
+
9
+ 1. **Live path** (`Postsvg.convert`): raw PS string → hand-written
10
+ `Tokenizer` → `Interpreter#interpret` (a 900-line stack machine that
11
+ emits SVG strings inline via `emit_svg_path`).
12
+ 2. **Dormant Parslet path**: `Parser::PostscriptParser`,
13
+ `Parser::Transform`, `Converter`, plus an unused `SvgGenerator` and
14
+ `GraphicsState` that are only reached from `Converter`.
15
+
16
+ The README, gemspec dependency list (`parslet`), and most of `docs/`
17
+ describe the dormant Parslet path as if it were live. The live path has
18
+ no domain model — every operator reaches into raw hashes and arrays and
19
+ mutates them, which makes adding features (let alone a reverse
20
+ direction) unsafe.
21
+
22
+ We need:
23
+
24
+ - A real **domain model** (typed PS records) so the renderer and the
25
+ serializer can both consume the same data.
26
+ - A **renderer** that walks the model and emits SVG via a clean builder.
27
+ - A **serializer** that walks the model and emits PS source.
28
+ - A **parser** for SVG that produces a parallel `Svg::*` model.
29
+ - A **translation** layer that walks the SVG model and produces PS
30
+ records, which the serializer writes out.
31
+
32
+ All of this must be open for extension (OCP): new PS operator → new
33
+ record class + visitor arm; new SVG element → new value class + handler.
34
+ Existing code must not change to add new cases.
35
+
36
+ ## Target layering
37
+
38
+ ```
39
+ ┌─────────────────────┐
40
+ bytes ───▶│ Postsvg::Lexer │ comment-aware
41
+ └──────────┬──────────┘
42
+ │ tokens
43
+
44
+ ┌─────────────────────┐
45
+ │ Postsvg::Parser │ AST + proc inlining
46
+ └──────────┬──────────┘
47
+ │ Model::Program
48
+
49
+ ┌──────────────────────────────────────────────────────────────┐
50
+ │ Postsvg::Model::* (typed PS records, immutable value objs) │
51
+ │ Program, Statement, Procedure, Dictionary, Array, String, │
52
+ │ Number, Name, operators (Moveto, Lineto, Stroke, …) │
53
+ └──────────────────────────────────────────────────────────────┘
54
+ │ ▲
55
+ │ visit │ emit
56
+ ▼ │
57
+ ┌─────────────────────┐ ┌─────────────────────┐
58
+ │ Postsvg::Renderer │ │ Postsvg::Serializer│
59
+ │ + PsVisitor │ │ (Model → PS src) │
60
+ │ + SvgBuilder │ │ │
61
+ └──────────┬──────────┘ └──────────▲──────────┘
62
+ │ SVG string │ Model::Program
63
+ ▼ │
64
+ ┌─────────────────────┐ │
65
+ │ Postsvg::Svg::* │ parse │
66
+ │ Document, Element │────────────┐ │
67
+ └──────────▲──────────┘ │ │
68
+ │ ▼ │
69
+ │ ┌─────────────────────┐ │
70
+ │ │ Postsvg::Translation│ │
71
+ │ │ PsRenderer │ │
72
+ │ │ + HandlerRegistry │ │
73
+ │ │ + Handlers::* │ │
74
+ │ └──────────┬──────────┘ │
75
+ │ │ Model::Program
76
+ │ └───────────────┘
77
+
78
+ │ round-trip closes here: SVG → Model → SVG
79
+ ```
80
+
81
+ ## MECE responsibility split
82
+
83
+ | Namespace | Owns | Does NOT own |
84
+ |----------------------------|-------------------------------------|------------------------------------|
85
+ | `Postsvg::Lexer` | PS source → `Model::Token[]` | semantics, execution |
86
+ | `Postsvg::Parser` | tokens → `Model::Program` | rendering, serialization |
87
+ | `Postsvg::Model::*` | typed records, value equality | execution, IO |
88
+ | `Postsvg::GraphicsContext` | CTM, color, clip stack, font | emitting anything |
89
+ | `Postsvg::Matrix` | affine transforms | graphics state |
90
+ | `Postsvg::Color` | RGB / Gray / CMYK conversions | mutating state |
91
+ | `Postsvg::SvgBuilder` | SVG byte emission, dedup, viewbox | PS semantics |
92
+ | `Postsvg::Renderer` | PS → SVG orchestration | SVG parsing |
93
+ | `Postsvg::Visitors::PsVisitor` | dispatch Model record → builder | orchestration |
94
+ | `Postsvg::Svg::*` | SVG domain model (parse side) | PS knowledge |
95
+ | `Postsvg::Translation::*` | SVG model → PS Model records | SVG parsing, PS serialization |
96
+ | `Postsvg::Serializer` | Model records → PS source text | SVG |
97
+ | `Postsvg::CLI` | argument parsing, IO | conversion logic |
98
+
99
+ ## Constraints (non-negotiable)
100
+
101
+ These come from the user's global `~/.claude/CLAUDE.md` and from the
102
+ project's existing rules:
103
+
104
+ 1. **No `require_relative` in `lib/`.** Use Ruby `autoload` declared in
105
+ the immediate parent namespace file.
106
+ 2. **No `double()` in specs.** Real instances or `Struct.new`.
107
+ 3. **No `send` to private methods, no `instance_variable_set`/`get`,
108
+ no `respond_to?` for type checks.**
109
+ 4. **No AI attribution** anywhere.
110
+ 5. **Never delete source files.** The dormant Parslet pipeline
111
+ (`Parser`, `Converter`, `SvgGenerator`, `GraphicsState`,
112
+ `parser/postscript_parser.rb`, `parser/transform.rb`) stays in the
113
+ tree under a clearly-marked legacy path; it is no longer on the
114
+ autoload path of the public API.
115
+ 6. **Never commit to `main`, never push tags, never push to `main`.**
116
+ 7. **Library packages have no side effects.** No writes to
117
+ `__dir__`/`import.meta.url`-style paths.
118
+ 8. **DRY / OCP / MECE.** Adding a PS operator or an SVG element must
119
+ not require editing a switch statement; it must be a class + a
120
+ registration.
121
+
122
+ ## What we do NOT do here
123
+
124
+ - We do not chase byte-exact parity with Ghostscript or Inkscape —
125
+ that is a separate goal and is incompatible with the pure-Ruby,
126
+ no-external-deps constraint.
127
+ - We do not implement Level 3 shading, CID fonts, or image filters in
128
+ the P0 / P1 scope. Those are P3.
129
+ - We do not delete the Parslet pipeline. It is preserved for reference
130
+ and for any future migration.
131
+
132
+ ## Source materials
133
+
134
+ - `../postscript-guide/docs/` — operator reference (Levels 1/2/3),
135
+ syntax, data types. Use this for operator semantics, stack effects,
136
+ and edge cases.
137
+ - `../emfsvg/` — architectural template (Renderer / Visitor / SvgBuilder
138
+ for forward direction; Svg::Document / Translation::EmfRenderer /
139
+ HandlerRegistry / Handlers::* for reverse direction).
@@ -0,0 +1,39 @@
1
+ # 01 — Migrate `lib/` to Ruby `autoload`
2
+
3
+ ## Status: [x]
4
+
5
+ ## Why
6
+
7
+ The repo's global rule (see `~/.claude/CLAUDE.md` rule 9) forbids
8
+ `require_relative` (and `require` with paths inside the library) in
9
+ library code. Reasons: lazy loading, no circular-load crashes, clean
10
+ `$LOAD_PATH`, and a single place to read "what does this namespace
11
+ expose."
12
+
13
+ ## Tasks
14
+
15
+ - [x] Define all `autoload` entries for `Postsvg` in `lib/postsvg.rb`.
16
+ - [x] Create namespace files for sub-namespaces:
17
+ - `lib/postsvg/model.rb` — `Postsvg::Model` autoloads
18
+ - `lib/postsvg/visitors.rb` — `Postsvg::Visitors` autoloads
19
+ - `lib/postsvg/svg.rb` — `Postsvg::Svg` autoloads
20
+ - `lib/postsvg/translation.rb` — `Postsvg::Translation` autoloads
21
+ - `lib/postsvg/model/operators.rb` — `Postsvg::Model::Operators`
22
+ - `lib/postsvg/svg/elements.rb` — `Postsvg::Svg::Elements`
23
+ - `lib/postsvg/translation/handlers.rb` — `Postsvg::Translation::Handlers`
24
+ - [x] Remove every `require_relative` in `lib/` (each file relies on its
25
+ parent namespace's autoload declaration).
26
+ - [x] Keep `require "parslet"`, `require "thor"`, `require "nokogiri"`
27
+ in `lib/postsvg.rb` only (external gems).
28
+ - [x] Verify `ruby -Ilib -e "require 'postsvg'; Postsvg.convert('...')"`
29
+ still works.
30
+
31
+ ## Rule of thumb
32
+
33
+ Each `lib/foo/bar/baz.rb` file defines `class Foo::Bar::Baz`. The
34
+ *parent* (`lib/foo/bar.rb`) declares `autoload :Baz, "foo/bar/baz"`.
35
+ The root (`lib/foo.rb`) declares `autoload :Bar, "foo/bar"`.
36
+
37
+ This means: never reach across namespaces. If `Baz` needs `Quux`, do
38
+ not `require_relative "../quux"` — the parent of `Baz` already
39
+ autoloaded `Quux` (or will, when something references it).
@@ -0,0 +1,51 @@
1
+ # 02 — Isolate dormant code (no deletion)
2
+
3
+ ## Status: [x]
4
+
5
+ ## Why
6
+
7
+ The repo's first absolute rule is **NEVER DELETE source files**. The
8
+ following files exist but are not on the live conversion path:
9
+
10
+ - `lib/postsvg/converter.rb` (class `Postsvg::Converter`)
11
+ - `lib/postsvg/parser.rb` (class `Postsvg::Parser`)
12
+ - `lib/postsvg/parser/postscript_parser.rb` (Parslet grammar)
13
+ - `lib/postsvg/parser/transform.rb` (Parslet AST transform)
14
+ - `lib/postsvg/svg_generator.rb` (class `Postsvg::SvgGenerator`)
15
+ - `lib/postsvg/graphics_state.rb` (class `Postsvg::GraphicsState`)
16
+ - `lib/postsvg/interpreter.rb` (class `Postsvg::Interpreter` — the
17
+ pre-refactor live path)
18
+ - `lib/postsvg/tokenizer.rb` (class `Postsvg::Tokenizer` — pre-refactor
19
+ lexer)
20
+
21
+ These remain useful as reference material (the dormant Parslet grammar
22
+ in particular encodes operator-precedence and syntax insights the new
23
+ parser will want to consult). They must NOT be deleted.
24
+
25
+ ## Tasks
26
+
27
+ - [x] Move these files under `lib/postsvg/legacy/` to make their status
28
+ unambiguous (filesystem move, not deletion; `git mv` preserves
29
+ history).
30
+ - [x] Add a module docstring to each: "Legacy implementation, kept for
31
+ reference. Not on the public autoload path."
32
+ - [x] Do not declare autoload entries for them in `lib/postsvg.rb`.
33
+ - [x] Add a README in `lib/postsvg/legacy/` explaining why they are
34
+ there and how the new pipeline supersedes them.
35
+ - [x] Keep their specs (if any) green by adjusting require paths in the
36
+ spec files only — never in the legacy lib files themselves.
37
+
38
+ ## What this is NOT
39
+
40
+ - Not deletion.
41
+ - Not renaming the public classes (the legacy `Postsvg::Converter`
42
+ etc. keep their names; they just live in a different file).
43
+ - Not a deprecation cycle with warnings — they are simply not loaded
44
+ by the public API anymore.
45
+
46
+ ## Roll-forward path (future, out of scope)
47
+
48
+ If/when we want to merge the Parslet grammar's precedence rules into
49
+ the new `Parser`, we can do it as a separate refactor that deletes the
50
+ `legacy/` directory — at that point it is a deliberate replacement,
51
+ not deletion of "unused" code.
@@ -0,0 +1,66 @@
1
+ # 03 — PS Domain Model (`Postsvg::Model::*`)
2
+
3
+ ## Status: [x]
4
+
5
+ ## Why
6
+
7
+ A typed record layer is the backbone of bidirectional conversion:
8
+
9
+ - The PS→SVG renderer walks records and emits SVG.
10
+ - The SVG→PS translator builds records and hands them to the serializer.
11
+ - Round-trip tests assert against the record list, not against string
12
+ fragments.
13
+
14
+ Without a model, every operator is bespoke string manipulation. With a
15
+ model, every operator is a value object that participates in equality,
16
+ serialization, and dispatch.
17
+
18
+ ## Tasks
19
+
20
+ - [x] `Model::Token` — lexical token (type, value, position).
21
+ Replaces the bare `Struct` in legacy `Tokenizer`.
22
+ - [x] `Model::Number`, `Model::Name`, `Model::StringLiteral`,
23
+ `Model::HexLiteral`, `Model::Array`, `Model::Procedure`,
24
+ `Model::Dictionary` — literal value objects.
25
+ - [x] `Model::Program` — root AST node; carries `header` (DSC comments)
26
+ and `body` (statement list).
27
+ - [x] `Model::Operators::*` — one class per PS operator
28
+ (see `11-operator-coverage.md` for the full list).
29
+ Each is a frozen `Struct`-like value with:
30
+ - `keyword` — the PS operator name (e.g. `"moveto"`)
31
+ - typed operand fields
32
+ - `accept(visitor, ctx)` — double-dispatch entry point
33
+ - [x] Operators grouped by category (`Operators::Path::*`,
34
+ `Operators::Painting::*`, etc.) — MECE by domain, not by file count.
35
+
36
+ ## Operator base class
37
+
38
+ ```ruby
39
+ class Model::Operator
40
+ def self.keyword(name)
41
+ define_method(:keyword) { name }
42
+ end
43
+
44
+ def accept(visitor, ctx)
45
+ visitor.public_send(:"visit_#{self.class.visit_name}", self, ctx)
46
+ end
47
+ end
48
+ ```
49
+
50
+ `visitor.visit_moveto(op, ctx)` is public. No `send` to private
51
+ methods; the visitor's API is intentionally public.
52
+
53
+ ## Why Struct (not OpenStruct or hand-rolled classes)
54
+
55
+ - `Struct` gives `==`, `hash`, `members`, `to_h`, and `[]` for free.
56
+ - Frozen Structs are immutable value objects.
57
+ - Subclassing `Struct.new(...)` works for adding behaviour without
58
+ losing value semantics.
59
+
60
+ ## What goes here vs. `GraphicsContext`
61
+
62
+ - **Model** = the *program* (what was said).
63
+ - **GraphicsContext** = the *interpreter state* (what's true right now
64
+ as the program executes).
65
+
66
+ The visitor mutates the context; it does not mutate the model.
@@ -0,0 +1,40 @@
1
+ # 04 — PS Lexer (`Postsvg::Lexer`)
2
+
3
+ ## Status: [x]
4
+
5
+ ## Why
6
+
7
+ The legacy `Tokenizer` strips comments with a global
8
+ `gsub(/%[^\n\r]*/, " ")` *before* tokenizing. This corrupts `%`
9
+ characters inside string literals (`(100% off)` becomes `(100 off)`).
10
+
11
+ The new lexer must:
12
+
13
+ - Be comment-aware: distinguish `%` starting a line comment from `%`
14
+ inside `(...)` strings and `<...>` hex strings.
15
+ - Carry source positions (line, column) for error messages.
16
+ - Emit `Model::Token` value objects, not bare `Struct`s.
17
+ - Never mutate the input.
18
+
19
+ ## Tasks
20
+
21
+ - [x] `Lexer.tokenize(source) -> [Model::Token]` — public entry.
22
+ - [x] State machine with these states:
23
+ - `:top` — normal scanning
24
+ - `:string` — inside `(...)`, tracks nesting, honours `\` escapes
25
+ - `:hexstring` — inside `<...>`, stops at `>`
26
+ - `:line_comment` — from `%` to end of line, but only when in `:top`
27
+ - [x] Token types: `:number`, `:name` (with leading `/` stripped,
28
+ stored as a `Name` literal flag), `:operator`, `:string`,
29
+ `:hexstring`, `:proc_open`, `:proc_close`, `:array_open`,
30
+ `:array_close`, `:dict_open`, `:dict_close`.
31
+ - [x] Numeric scanner handles: integers, decimals, leading-dot,
32
+ trailing-dot, scientific notation, leading sign.
33
+ - [x] DSC comments (`%%BoundingBox:` etc.) are captured as a special
34
+ `:dsc` token so the parser can extract header metadata without
35
+ re-parsing source.
36
+
37
+ ## Out of scope
38
+
39
+ - Binary tokens (Level 2 binary object format). Future work; tracked
40
+ in `23-level2-level3.md`.
@@ -0,0 +1,43 @@
1
+ # 05 — PS Parser (`Postsvg::Parser`)
2
+
3
+ ## Status: [x]
4
+
5
+ ## Why
6
+
7
+ Turn the token stream into a typed `Model::Program`. The legacy
8
+ `Interpreter` mixed lexing, parsing, and execution in one pass; we
9
+ separate them so that:
10
+
11
+ - The renderer can pre-scan for `%%BoundingBox`, `%%Pages`, etc.
12
+ - The serializer can walk a typed AST.
13
+ - Tests can assert on the AST, not on side effects.
14
+
15
+ ## Tasks
16
+
17
+ - [x] `Parser.parse(tokens) -> Model::Program`.
18
+ - [x] Procedure bodies (`{ ... }`) become `Model::Procedure` nodes
19
+ carrying nested tokens — *not* flattened into the stream.
20
+ - [x] Arrays (`[ ... ]`) become `Model::Array` literals.
21
+ - [x] Dictionaries (`<< ... >>`) become `Model::Dictionary` literals.
22
+ - [x] DSC comments (`%%BoundingBox:`, `%%Title:`, `%%Pages:`, etc.)
23
+ populate `Model::Program#header`.
24
+ - [x] Operator dispatch: when an operator token is consumed, pop the
25
+ right number of operands off an internal parse stack and construct
26
+ the corresponding `Model::Operators::*` instance. *Unknown*
27
+ operators stay as `Model::UnknownOperator` so the visitor can warn
28
+ rather than crash.
29
+ - [x] PostScript's `def` defines names that may later be referenced as
30
+ operators. The parser records these in a `definitions` map; on
31
+ encountering a bare name that resolves to a `Model::Procedure`, it
32
+ emits a `Model::InvokeProcedure` node carrying the procedure body.
33
+
34
+ ## Design choice: do we inline procedures?
35
+
36
+ The legacy `Interpreter` splices procedure tokens back into the token
37
+ stream (`tokens.insert(current_index + 1, *proc_tokens)`). This is
38
+ fast but loses structure.
39
+
40
+ The new parser keeps procedures as values; the **renderer** is
41
+ responsible for descending into them when they are invoked. This makes
42
+ the AST stable across passes (useful for the renderer's bounding-box
43
+ pre-scan) and lets the serializer emit procedures verbatim.
@@ -0,0 +1,45 @@
1
+ # 06 — Graphics Context
2
+
3
+ ## Status: [x]
4
+
5
+ ## Why
6
+
7
+ The legacy code has two graphics-state classes:
8
+
9
+ - `Postsvg::GraphicsState` — only reached by the dormant `Converter`.
10
+ - `@g_state` hash inside `Interpreter` — the live state, untyped.
11
+
12
+ Neither is reusable for the new pipeline. We need one
13
+ direction-agnostic value object that:
14
+
15
+ - Carries CTM, colors, line style, font, dash pattern, clip stack.
16
+ - Is **immutable**; "saving" state pushes a snapshot onto a stack,
17
+ "restoring" pops back to the previous snapshot.
18
+ - Does not own SVG/PS-specific emission logic.
19
+
20
+ ## Tasks
21
+
22
+ - [x] `Postsvg::GraphicsContext` — frozen `Struct`-based value object.
23
+ Fields: `ctm`, `fill_color`, `stroke_color`, `stroke_width`,
24
+ `line_cap`, `line_join`, `miter_limit`, `dash`, `font_name`,
25
+ `font_size`, `clip_stack`, `last_text_position`, `fill_rule`.
26
+ - [x] `Postsvg::GraphicsStack` — push/pop of immutable snapshots.
27
+ `grestore` pops; an attempt to pop an empty stack is a no-op (mirrors
28
+ PS spec — `grestore` with empty stack is a no-op).
29
+ - [x] Defaults match PLRM defaults: black fill, 1pt stroke, butt cap,
30
+ miter join, miter limit 10, identity CTM, Helvetica 12pt.
31
+
32
+ ## Why immutable
33
+
34
+ - Snapshots are O(1) reference copies; no deep dup.
35
+ - Push/pop cannot accidentally share mutable state across snapshots
36
+ (which was a recurring bug source in the legacy `GraphicsState`
37
+ where `@transform_matrix` was an in-place array).
38
+ - Round-trip tests can compare snapshots for equality.
39
+
40
+ ## CoordinateState parallel
41
+
42
+ `emfsvg` has a `CoordinateState` that is deliberately shared across
43
+ `SaveDC`/`RestoreDC` snapshots (GDI quirk). PostScript has no such
44
+ quirk — every `gsave`/`grestore` is a full state save — so we do not
45
+ need a parallel struct here.
@@ -0,0 +1,37 @@
1
+ # 07 — Matrix and Color
2
+
3
+ ## Status: [x]
4
+
5
+ ## Why
6
+
7
+ Both directions need affine transforms and color conversions. Today
8
+ these are spread across `lib/postsvg/matrix.rb` (one good class),
9
+ `lib/postsvg/colors.rb` (a module of free functions), and ad-hoc
10
+ hex/rgb formatting scattered through the interpreter.
11
+
12
+ ## Tasks
13
+
14
+ - [x] `Postsvg::Matrix` — already exists and is mostly correct. Keep
15
+ its API; freeze the instance on construction. Add `with(a:, b:, c:,
16
+ d:, e:, f:)` for functional updates so callers never mutate.
17
+ - [x] `Postsvg::Color` — value object with `red`, `green`, `blue` in
18
+ `[0, 255]`, plus constructors `Color.rgb`, `Color.gray`,
19
+ `Color.cmyk`, `Color.parse` (CSS `#rrggbb`, `rgb(...)`, named).
20
+ - [x] `Color#to_svg` — `"#rrggbb"` or `"rgb(r, g, b)"` based on
21
+ readability (hex by default).
22
+ - [x] `Color#to_ps_setrgbcolor` — `"r g b setrgbcolor"` with PS-native
23
+ `[0,1]` floats.
24
+ - [x] `Color#to_ps_setgray` — when the color is gray, emit shorter
25
+ `setgray` form.
26
+ - [x] Drop the free-function `Colors` module in favour of `Color`
27
+ instances. (The legacy module stays under `legacy/`.)
28
+
29
+ ## Numeric formatting helper
30
+
31
+ `Postsvg::FormatNumber` — single source of truth for both directions:
32
+
33
+ - Integers print without trailing `.0`.
34
+ - Floats print to 4 decimals, trailing zeros stripped.
35
+ - `-0` normalized to `0`.
36
+
37
+ Replaces the three copies of `num_fmt` in the legacy code.
@@ -0,0 +1,51 @@
1
+ # 08 — SvgBuilder
2
+
3
+ ## Status: [x]
4
+
5
+ ## Why
6
+
7
+ The legacy `Interpreter` emits SVG by string concatenation inline with
8
+ execution. This means:
9
+
10
+ - No central place to enforce XML well-formedness.
11
+ - No way to dedup clipPaths / gradients / patterns across the program.
12
+ - Hard to test (every operator test has to assert on a fragment).
13
+
14
+ ## Tasks
15
+
16
+ - [x] `Postsvg::SvgBuilder` — append-only emitter with this surface:
17
+ - `#open_svg(viewbox:, width:, height:)`
18
+ - `#close_svg`
19
+ - `#open_group(transform: nil, clip_path_id: nil, attrs: {})`
20
+ - `#close_group`
21
+ - `#path(d:, fill:, stroke:, stroke_width:, line_cap:, line_join:,
22
+ dash:, clip_path_id:)`
23
+ - `#text(content:, x:, y:, font_family:, font_size:, fill:, attrs:)`
24
+ - `#image(href:, x:, y:, width:, height:, transform:)`
25
+ - `#register_clip_path(d:) -> id` — returns existing id if `d` is
26
+ byte-equal to a previously-registered path.
27
+ - `#register_linear_gradient(...)` / `#register_radial_gradient(...)`
28
+ — same dedup contract.
29
+ - `#register_pattern(...)` — same.
30
+ - `#to_s` — final SVG string.
31
+ - [x] XML escaping centralized in `SvgBuilder#escape` (no copy in
32
+ callers).
33
+ - [x] All shape/text/gradient methods return `self` so calls chain.
34
+ - [x] Deterministic ordering: `<defs>` block emits clipPaths first,
35
+ then gradients, then patterns — sorted by registration order so IDs
36
+ are reproducible.
37
+ - [x] The Y-flip wrapper `<g transform="translate(0 H) scale(1 -1)">`
38
+ is the builder's responsibility, not the renderer's. The renderer
39
+ emits shapes in PS-native coordinates; the builder wraps them.
40
+
41
+ ## Determinism invariants
42
+
43
+ - Two runs of the same input produce byte-equal SVG.
44
+ - ID counters start at 1 on every `SvgBuilder.new`.
45
+ - Floating-point output goes through `FormatNumber` (see `07`).
46
+
47
+ ## Out of scope
48
+
49
+ - Byte-exact parity with Ghostscript or any external tool. Postsvg
50
+ produces clean, valid SVG — it does not reproduce another tool's
51
+ formatting quirks (unlike `emfsvg`'s libemf2svg parity goal).
@@ -0,0 +1,52 @@
1
+ # 09 — Renderer (PS → SVG orchestrator)
2
+
3
+ ## Status: [x]
4
+
5
+ ## Why
6
+
7
+ The renderer is the entry point for PS/EPS → SVG. It owns the lifecycle
8
+ that the legacy `Interpreter` did, but with clean boundaries:
9
+
10
+ ```
11
+ source ──▶ Lexer ──▶ Parser ──▶ Model::Program
12
+
13
+
14
+ Renderer#call
15
+
16
+ ┌────────────┴───────────┐
17
+ │ GraphicsStack │
18
+ │ PathBuilder │
19
+ │ SvgBuilder │
20
+ │ PsVisitor │
21
+ └────────────┬───────────┘
22
+
23
+
24
+ SVG string
25
+ ```
26
+
27
+ ## Tasks
28
+
29
+ - [x] `Postsvg::Renderer.call(program, options) -> String`.
30
+ - [x] `Renderer::Options` frozen struct: `eps:`, `width:`, `height:`,
31
+ `viewbox_override:`, `verbose:`.
32
+ - [x] BoundingBox pre-scan: walk the program once to find any drawing
33
+ outside the declared BoundingBox; if found, expand viewbox or warn.
34
+ - [x] Single pass execution: walk `program.body`, dispatch each
35
+ statement via `PsVisitor`, accumulate output in `SvgBuilder`.
36
+ - [x] Procedure invocation: when the visitor hits
37
+ `Model::InvokeProcedure`, it descends into the procedure body with
38
+ the current context. Recursion limit (default 64) prevents infinite
39
+ loops from mutually-recursive procedures.
40
+ - [x] `MAX_OUTPUT_BYTES` guard (mirrors `emfsvg`): raise
41
+ `RenderError` if output exceeds 100 MB. Pure-Ruby, no memory cap
42
+ enforcement beyond this.
43
+
44
+ ## Why a separate Renderer and Visitor
45
+
46
+ - The **Renderer** owns lifecycle (open/close SVG, viewbox, options).
47
+ - The **Visitor** owns per-record semantics (what does `moveto` do to
48
+ the path builder?).
49
+
50
+ Splitting them means the Visitor can be unit-tested with a stub
51
+ Renderer (a real `SvgBuilder` instance, no double) and reused for any
52
+ future "render to non-SVG" target (e.g. an in-memory shape tree).