ranger-compiler 3.5.1 → 3.5.2

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 (338) hide show
  1. package/CHANGELOG.md +1064 -20
  2. package/LICENSE +3 -1
  3. package/README.md +123 -28
  4. package/dist/Lang.rgr +1188 -290
  5. package/dist/api.d.ts +2343 -879
  6. package/dist/api.js +79923 -56921
  7. package/dist/lib/JSON.rgr +102 -91
  8. package/dist/lib/Shell.rgr +4 -4
  9. package/dist/lib/apple/AppleToolchain.rgr +4 -4
  10. package/dist/lib/apple/README.md +5 -4
  11. package/dist/lib/apple/apple_test.rgr +1 -1
  12. package/dist/lib/core/README.md +1 -1
  13. package/dist/lib/evg/EVG.rgr +12 -0
  14. package/dist/lib/evg/EVGA11yFromTree.rgr +302 -0
  15. package/dist/lib/evg/EVGA11yTree.rgr +894 -0
  16. package/dist/lib/evg/EVGBox.rgr +267 -0
  17. package/dist/lib/evg/EVGBoxShorthandTest.rgr +220 -0
  18. package/dist/lib/evg/EVGCodepoint.rgr +316 -0
  19. package/dist/lib/evg/EVGColor.rgr +700 -0
  20. package/dist/lib/evg/EVGCommands.rgr +177 -0
  21. package/dist/lib/evg/EVGComponent.rgr +331 -0
  22. package/dist/lib/evg/EVGComponentTest.rgr +387 -0
  23. package/dist/lib/evg/EVGConnector.rgr +541 -0
  24. package/dist/lib/evg/EVGConnectorTest.rgr +306 -0
  25. package/dist/lib/evg/EVGDisplayList.rgr +4186 -0
  26. package/dist/lib/evg/EVGEasing.rgr +370 -0
  27. package/dist/lib/evg/EVGEffectTest.rgr +253 -0
  28. package/dist/lib/evg/EVGElement.rgr +4914 -0
  29. package/dist/lib/evg/EVGFixedTest.rgr +289 -0
  30. package/dist/lib/evg/EVGFlexRulesTest.rgr +317 -0
  31. package/dist/lib/evg/EVGFlexWrapTest.rgr +246 -0
  32. package/dist/lib/evg/EVGFling.rgr +244 -0
  33. package/dist/lib/evg/EVGFocus.rgr +400 -0
  34. package/dist/lib/evg/EVGFocusTest.rgr +399 -0
  35. package/dist/lib/evg/EVGGradient.rgr +322 -0
  36. package/dist/lib/evg/EVGGrapheme.rgr +190 -0
  37. package/dist/lib/evg/EVGGrid.rgr +975 -0
  38. package/dist/lib/evg/EVGHitTest.rgr +208 -0
  39. package/dist/lib/evg/EVGHoles.rgr +294 -0
  40. package/dist/lib/evg/EVGHostMeasurerTest.rgr +281 -0
  41. package/dist/lib/evg/EVGHostTextMeasurer.rgr +331 -0
  42. package/dist/lib/evg/EVGHostTree.rgr +834 -0
  43. package/dist/lib/evg/EVGHostTreeTest.rgr +447 -0
  44. package/dist/lib/evg/EVGImageDecode.rgr +117 -0
  45. package/dist/lib/evg/EVGImageMeasurer.rgr +86 -0
  46. package/dist/lib/evg/EVGInspect.rgr +883 -0
  47. package/dist/lib/evg/EVGInvalidateTest.rgr +432 -0
  48. package/dist/lib/evg/EVGJsonTest.rgr +535 -0
  49. package/dist/lib/evg/EVGLayout.rgr +4220 -0
  50. package/dist/lib/evg/EVGMeasure.rgr +1267 -0
  51. package/dist/lib/evg/EVGOverlayTest.rgr +626 -0
  52. package/dist/lib/evg/EVGPatch.rgr +1721 -0
  53. package/dist/lib/evg/EVGPatchTest.rgr +692 -0
  54. package/dist/lib/evg/EVGPopoverTest.rgr +395 -0
  55. package/dist/lib/evg/EVGReconcile.rgr +226 -0
  56. package/dist/lib/evg/EVGReconcileTest.rgr +421 -0
  57. package/dist/lib/evg/EVGReject.rgr +126 -0
  58. package/dist/lib/evg/EVGRelayoutTest.rgr +375 -0
  59. package/dist/lib/evg/EVGRtlLayoutTest.rgr +319 -0
  60. package/dist/lib/evg/EVGRuler.rgr +232 -0
  61. package/dist/lib/evg/EVGRulerTest.rgr +172 -0
  62. package/dist/lib/evg/EVGSelectChrome.rgr +178 -0
  63. package/dist/lib/evg/EVGStyleCacheTest.rgr +419 -0
  64. package/dist/lib/evg/EVGStyleSheet.rgr +2148 -0
  65. package/dist/lib/evg/EVGStyleStateTest.rgr +407 -0
  66. package/dist/lib/evg/EVGStyleVarTest.rgr +471 -0
  67. package/dist/lib/evg/EVGText.rgr +105 -0
  68. package/dist/lib/evg/EVGTextEngine.rgr +536 -0
  69. package/dist/lib/evg/EVGTextMeasurer.rgr +720 -0
  70. package/dist/lib/evg/EVGTimingTest.rgr +1417 -0
  71. package/dist/lib/evg/EVGToolbar.rgr +1369 -0
  72. package/dist/lib/evg/EVGTransition.rgr +728 -0
  73. package/dist/lib/evg/EVGTreeJson.rgr +721 -0
  74. package/dist/lib/evg/EVGUnit.rgr +554 -0
  75. package/dist/lib/evg/EVGViewportUnitTest.rgr +236 -0
  76. package/dist/lib/evg/EvgApp.rgr +189 -0
  77. package/dist/lib/evg/EvgBitmapTracer.rgr +4277 -0
  78. package/dist/lib/evg/EvgBitmapTracerTest.rgr +2119 -0
  79. package/dist/lib/evg/EvgHost.rgr +451 -0
  80. package/dist/lib/evg/EvgTest.rgr +91 -0
  81. package/dist/lib/evg/EvgTraceColor.rgr +236 -0
  82. package/dist/lib/evg/EvgTraceCurve.rgr +648 -0
  83. package/dist/lib/evg/EvgTraceFit.rgr +767 -0
  84. package/dist/lib/evg/EvgTracePath.rgr +435 -0
  85. package/dist/lib/evg/EvgTraceTypes.rgr +559 -0
  86. package/dist/lib/evg/EvgViewport.rgr +313 -0
  87. package/dist/lib/evg/FxDemoDoc.rgr +185 -0
  88. package/dist/lib/evg/HOSTS.md +227 -0
  89. package/dist/lib/evg/ISSUES.md +835 -0
  90. package/dist/lib/evg/PLAN_ACCESSIBILITY.md +650 -0
  91. package/dist/lib/evg/PLAN_CSS_LAYOUT_AND_FONTS.md +1124 -0
  92. package/dist/lib/evg/PLAN_EFFECTS.md +431 -0
  93. package/dist/lib/evg/PLAN_EVG.md +518 -0
  94. package/dist/lib/evg/PLAN_EVG_RENDERER.md +1674 -0
  95. package/dist/lib/evg/PLAN_INSPECTOR.md +788 -0
  96. package/dist/lib/evg/PLAN_LINKS_AND_FORMS.md +157 -0
  97. package/dist/lib/evg/PLAN_NATIVE_HOSTS.md +671 -0
  98. package/dist/lib/evg/PLAN_VECTOR_IR.md +664 -0
  99. package/dist/lib/evg/PLAN_VIEW_TRANSFORM.md +322 -0
  100. package/dist/lib/evg/PathBuilder.rgr +333 -0
  101. package/dist/lib/evg/README.md +1236 -0
  102. package/dist/lib/evg/SPEC.md +1252 -0
  103. package/dist/lib/evg/SVGPathParser.rgr +1199 -0
  104. package/dist/lib/evg/SvgParser.rgr +1736 -0
  105. package/dist/lib/evg/TODO_EVG_RENDERER.md +595 -0
  106. package/dist/lib/evg/VectorShapes.rgr +331 -0
  107. package/dist/lib/evg/VectorStroke.rgr +107 -0
  108. package/dist/lib/evg/VectorViewBox.rgr +380 -0
  109. package/dist/lib/evg/agent/README.md +290 -0
  110. package/dist/lib/evg/agent/evg_agent.rgr +899 -0
  111. package/dist/lib/evg/agent/fixtures/broken.evg.json +7 -0
  112. package/dist/lib/evg/agent/fixtures/card.evg.json +9 -0
  113. package/dist/lib/evg/agent/fixtures/connector.css +63 -0
  114. package/dist/lib/evg/agent/fixtures/connector.evg.json +14 -0
  115. package/dist/lib/evg/agent/fixtures/drawn.evg.json +129 -0
  116. package/dist/lib/evg/agent/fixtures/gradient.evg.json +4 -0
  117. package/dist/lib/evg/agent/fixtures/popover.css +99 -0
  118. package/dist/lib/evg/agent/fixtures/popover.evg.json +30 -0
  119. package/dist/lib/evg/agent/roundtrip.sh +74 -0
  120. package/dist/lib/evg/agent/smoke.sh +230 -0
  121. package/dist/lib/evg/android/README.md +97 -0
  122. package/dist/lib/evg/android/androidstubs/AndroidStubs.kt +175 -0
  123. package/dist/lib/evg/android/androidstubs/Annotation.kt +10 -0
  124. package/dist/lib/evg/android/androidstubs/App.kt +30 -0
  125. package/dist/lib/evg/android/androidstubs/Content.kt +41 -0
  126. package/dist/lib/evg/android/androidstubs/ContentRes.kt +12 -0
  127. package/dist/lib/evg/android/androidstubs/InputMethod.kt +46 -0
  128. package/dist/lib/evg/android/androidstubs/Net.kt +6 -0
  129. package/dist/lib/evg/android/androidstubs/Os.kt +30 -0
  130. package/dist/lib/evg/android/androidstubs/Util.kt +15 -0
  131. package/dist/lib/evg/android/androidstubs/View.kt +113 -0
  132. package/dist/lib/evg/android/androidstubs/Widget.kt +16 -0
  133. package/dist/lib/evg/android/src/android/kotlin/fi/ranger/evg/AndroidEvgSurface.kt +323 -0
  134. package/dist/lib/evg/android/src/android/kotlin/fi/ranger/evg/AndroidTextMeasurer.kt +56 -0
  135. package/dist/lib/evg/android/src/android/kotlin/fi/ranger/evg/RippleEffect.kt +262 -0
  136. package/dist/lib/evg/android/src/awt/kotlin/fi/ranger/evg/AwtEvgSurface.kt +238 -0
  137. package/dist/lib/evg/android/src/awt/kotlin/fi/ranger/evg/AwtTextMeasurer.kt +85 -0
  138. package/dist/lib/evg/android/src/main/kotlin/fi/ranger/evg/EvgEngineThread.kt +115 -0
  139. package/dist/lib/evg/android/src/main/kotlin/fi/ranger/evg/EvgPainter.kt +231 -0
  140. package/dist/lib/evg/android/src/main/kotlin/fi/ranger/evg/EvgSurface.kt +117 -0
  141. package/dist/lib/evg/android/src/main/kotlin/fi/ranger/evg/RecordingSurface.kt +94 -0
  142. package/dist/lib/evg/apple/README.md +128 -0
  143. package/dist/lib/evg/apple/Sources/CoreGraphicsEvgSurface.swift +329 -0
  144. package/dist/lib/evg/apple/Sources/CoreTextMeasurer.swift +70 -0
  145. package/dist/lib/evg/apple/Sources/EvgEngineQueue.swift +105 -0
  146. package/dist/lib/evg/apple/Sources/EvgPainter.swift +225 -0
  147. package/dist/lib/evg/apple/Sources/EvgSurface.swift +163 -0
  148. package/dist/lib/evg/apple/Sources/RecordingSurface.swift +110 -0
  149. package/dist/lib/evg/bench/EvgLayoutBench.rgr +34 -0
  150. package/dist/lib/evg/bench/README.md +64 -0
  151. package/dist/lib/evg/bench/layout-bench.mjs +316 -0
  152. package/dist/lib/evg/bench/layout-cases.mjs +510 -0
  153. package/dist/lib/evg/bench/layout-conformance.mjs +254 -0
  154. package/dist/lib/evg/bin/.gitignore +17 -0
  155. package/dist/lib/evg/evg_test.rgr +313 -0
  156. package/dist/lib/evg/gl/README.md +198 -0
  157. package/dist/lib/evg/gl/a11y-paint-check.mjs +73 -0
  158. package/dist/lib/evg/gl/blur-check.mjs +399 -0
  159. package/dist/lib/evg/gl/boxmodel.json +1 -0
  160. package/dist/lib/evg/gl/demo.html +46 -0
  161. package/dist/lib/evg/gl/effect-presets.css +285 -0
  162. package/dist/lib/evg/gl/effect-presets.js +63 -0
  163. package/dist/lib/evg/gl/effect-shots.mjs +135 -0
  164. package/dist/lib/evg/gl/evg-a11y.js +566 -0
  165. package/dist/lib/evg/gl/evg-binary.js +160 -0
  166. package/dist/lib/evg/gl/evg-engine.js +296 -0
  167. package/dist/lib/evg/gl/evg-fx.js +237 -0
  168. package/dist/lib/evg/gl/evg-gestures.js +209 -0
  169. package/dist/lib/evg/gl/evg-list.js +167 -0
  170. package/dist/lib/evg/gl/evg-measure.js +186 -0
  171. package/dist/lib/evg/gl/evg-textinput.js +303 -0
  172. package/dist/lib/evg/gl/evg-view.js +144 -0
  173. package/dist/lib/evg/gl/evg-webgl.js +3942 -0
  174. package/dist/lib/evg/gl/fx-check.mjs +998 -0
  175. package/dist/lib/evg/gl/fx-demo.html +166 -0
  176. package/dist/lib/evg/gl/fx-demo.js +2 -0
  177. package/dist/lib/evg/gl/fx-serve.mjs +47 -0
  178. package/dist/lib/evg/gl/gestures-check.mjs +189 -0
  179. package/dist/lib/evg/gl/list-binary-check.mjs +152 -0
  180. package/dist/lib/evg/gl/measure-check.mjs +135 -0
  181. package/dist/lib/evg/gl/rotation-check.mjs +207 -0
  182. package/dist/lib/evg/gl/shift-check.mjs +113 -0
  183. package/dist/lib/evg/gl/stroke-check.mjs +168 -0
  184. package/dist/lib/evg/gl/text-snap-check.mjs +139 -0
  185. package/dist/lib/evg/gl/view-check.mjs +343 -0
  186. package/dist/lib/evg/gl/view-policy-check.mjs +184 -0
  187. package/dist/lib/evg/html/evg-dom.js +318 -0
  188. package/dist/lib/evg/html/evg-html.js +600 -0
  189. package/dist/lib/evg/inspect/README.md +415 -0
  190. package/dist/lib/evg/inspect/browser-smoke.mjs +115 -0
  191. package/dist/lib/evg/inspect/evg-inspect.js +947 -0
  192. package/dist/lib/evg/inspect/shots/css.png +0 -0
  193. package/dist/lib/evg/inspect/shots/dashboard.png +0 -0
  194. package/dist/lib/evg/inspect/shots/pptx-slide.png +0 -0
  195. package/dist/lib/evg/inspect/shots/state.png +0 -0
  196. package/dist/lib/evg/inspect/shots.mjs +226 -0
  197. package/dist/lib/evg/oracle/css-blur.json +576 -0
  198. package/dist/lib/evg/oracle/css-box.json +157 -0
  199. package/dist/lib/evg/oracle/css-timing.json +709 -0
  200. package/dist/lib/evg/oracle/css_blur_oracle.mjs +529 -0
  201. package/dist/lib/evg/oracle/css_box_oracle.mjs +106 -0
  202. package/dist/lib/evg/oracle/css_timing_oracle.mjs +389 -0
  203. package/dist/lib/evg/original/EVGColor.clj +310 -0
  204. package/dist/lib/evg/original/EVGColorContext.rgr +178 -0
  205. package/dist/lib/evg/original/SVGPath.rgr +627 -0
  206. package/dist/lib/evg/original/Vec2.crgr +103 -0
  207. package/dist/lib/evg/ranger.json +9 -0
  208. package/dist/lib/evg/showcase/README.md +246 -0
  209. package/dist/lib/evg/showcase/assets/emblem.svg +30 -0
  210. package/dist/lib/evg/showcase/assets/rosette.svg +22 -0
  211. package/dist/lib/evg/showcase/build.mjs +651 -0
  212. package/dist/lib/evg/showcase/pages/album.tsx +30 -0
  213. package/dist/lib/evg/showcase/pages/boxmodel.tsx +37 -0
  214. package/dist/lib/evg/showcase/pages/cards.tsx +41 -0
  215. package/dist/lib/evg/showcase/pages/chart_api.tsx +170 -0
  216. package/dist/lib/evg/showcase/pages/charts.tsx +219 -0
  217. package/dist/lib/evg/showcase/pages/drawing.tsx +160 -0
  218. package/dist/lib/evg/showcase/pages/emoji.tsx +91 -0
  219. package/dist/lib/evg/showcase/pages/flex.tsx +46 -0
  220. package/dist/lib/evg/showcase/pages/more.tsx +332 -0
  221. package/dist/lib/evg/showcase/pages/plots.tsx +262 -0
  222. package/dist/lib/evg/showcase/pages/svg.tsx +63 -0
  223. package/dist/lib/evg/showcase/pages/tables.tsx +209 -0
  224. package/dist/lib/evg/showcase/pages/typography.tsx +43 -0
  225. package/dist/lib/evg/showcase/pages/units.tsx +35 -0
  226. package/dist/lib/evg/showcase/pages/variants.tsx +233 -0
  227. package/dist/lib/evg/showcase/pages/vector.tsx +75 -0
  228. package/dist/lib/evg/showcase/pages/views.tsx +223 -0
  229. package/dist/lib/evg/showcase/tests/chart_api_smoke.mjs +339 -0
  230. package/dist/lib/evg/showcase/tests/gl_smoke.mjs +125 -0
  231. package/dist/lib/evg/showcase/themes/chart_api-default.css +15 -0
  232. package/dist/lib/evg/showcase/themes/charts-default.css +29 -0
  233. package/dist/lib/evg/showcase/themes/drawing-default.css +35 -0
  234. package/dist/lib/evg/showcase/themes/more-default.css +128 -0
  235. package/dist/lib/evg/showcase/themes/plots-default.css +18 -0
  236. package/dist/lib/evg/showcase/themes/showcase.css +508 -0
  237. package/dist/lib/evg/showcase/themes/tables-default.css +123 -0
  238. package/dist/lib/evg/showcase/themes/variants-default.css +15 -0
  239. package/dist/lib/evg/showcase/themes/views-default.css +15 -0
  240. package/dist/lib/evg/tools/bench_vs_potrace.mjs +290 -0
  241. package/dist/lib/evg/tools/evg_image_tool.rgr +488 -0
  242. package/dist/lib/evg/tools/evg_trace_bench.rgr +114 -0
  243. package/dist/lib/evg/tools/evg_trace_cli.rgr +662 -0
  244. package/dist/lib/evg/tools/evg_trace_cpp_bench.rgr +83 -0
  245. package/dist/lib/evg/tools/run_trace_bench.sh +74 -0
  246. package/dist/lib/evg/tools/run_trace_cli_smoke.sh +128 -0
  247. package/dist/lib/evg/web/responsive/EvgResponsiveCheck.rgr +271 -0
  248. package/dist/lib/evg/web/responsive/EvgResponsiveDemo.rgr +531 -0
  249. package/dist/lib/evg/web/responsive/README.md +97 -0
  250. package/dist/lib/evg/web/responsive/build.mjs +90 -0
  251. package/dist/lib/evg/web/responsive/dom-check.mjs +169 -0
  252. package/dist/lib/evg/web/responsive/index.html +177 -0
  253. package/dist/lib/evg/web/responsive/smoke.mjs +221 -0
  254. package/dist/lib/evg/web/tools/assets-client.mjs +95 -0
  255. package/dist/lib/evg/web/tools/boot-bench.mjs +189 -0
  256. package/dist/lib/evg/web/tools/inline-assets.mjs +119 -0
  257. package/dist/lib/evg/web/tools/minify.mjs +60 -0
  258. package/dist/lib/evg/web/tracer/build.mjs +87 -0
  259. package/dist/lib/evg/web/tracer/index.html +2805 -0
  260. package/dist/lib/evg/web/tracer/sample.png +0 -0
  261. package/dist/lib/evg/web/tracer/smoke.mjs +1162 -0
  262. package/dist/lib/evgr/Cargo.lock +21 -0
  263. package/dist/lib/evgr/Cargo.toml +19 -0
  264. package/dist/lib/evgr/README.md +140 -0
  265. package/dist/lib/evgr/bench/NativeBench.rgr +246 -0
  266. package/dist/lib/evgr/bench/compare.mjs +252 -0
  267. package/dist/lib/evgr/bench/speed.mjs +277 -0
  268. package/dist/lib/evgr/bin/.gitignore +3 -0
  269. package/dist/lib/evgr/src/bin/bench.rs +106 -0
  270. package/dist/lib/evgr/src/bin/smoke.rs +13 -0
  271. package/dist/lib/evgr/src/grid.rs +730 -0
  272. package/dist/lib/evgr/src/lib.rs +1003 -0
  273. package/dist/lib/evgr/src/style.rs +468 -0
  274. package/dist/lib/evgr/src/text.rs +83 -0
  275. package/dist/lib/image/BitReader.rgr +171 -0
  276. package/dist/lib/image/Buffer.rgr +173 -0
  277. package/dist/lib/image/DCT.rgr +283 -0
  278. package/dist/lib/image/Deflate.rgr +339 -0
  279. package/dist/lib/image/HuffmanDecoder.rgr +181 -0
  280. package/dist/lib/image/ImageBuffer.rgr +706 -0
  281. package/dist/lib/image/JPEGDecoder.rgr +808 -0
  282. package/dist/lib/image/PNGDecoder.rgr +544 -0
  283. package/dist/lib/image/PNGEncoder.rgr +388 -0
  284. package/dist/lib/image/PPMImage.rgr +224 -0
  285. package/dist/lib/image/ProgressiveJPEGDecoder.rgr +1154 -0
  286. package/dist/lib/image/README.md +33 -0
  287. package/dist/lib/image/RasterBuffer.rgr +294 -0
  288. package/dist/lib/image/VP8BoolDecoder.rgr +163 -0
  289. package/dist/lib/image/WebPDecoder.rgr +433 -0
  290. package/dist/lib/image/WebPLossless.rgr +1035 -0
  291. package/dist/lib/image/WebPLossy.rgr +1929 -0
  292. package/dist/lib/image/ranger.json +9 -0
  293. package/dist/lib/image/testdata/webp/anim_2f_24x16.webp +0 -0
  294. package/dist/lib/image/testdata/webp/fixtures.txt +33 -0
  295. package/dist/lib/image/testdata/webp/gen_fixtures.py +261 -0
  296. package/dist/lib/image/testdata/webp/ll_1x1.webp +0 -0
  297. package/dist/lib/image/testdata/webp/ll_alpha_29x21.webp +0 -0
  298. package/dist/lib/image/testdata/webp/ll_gradient_64x64.webp +0 -0
  299. package/dist/lib/image/testdata/webp/ll_meta_32x32.webp +0 -0
  300. package/dist/lib/image/testdata/webp/ll_noise_17x9.webp +0 -0
  301. package/dist/lib/image/testdata/webp/ll_pal11_23x11.webp +0 -0
  302. package/dist/lib/image/testdata/webp/ll_pal2_19x7.webp +0 -0
  303. package/dist/lib/image/testdata/webp/ll_pal40_16x16.webp +0 -0
  304. package/dist/lib/image/testdata/webp/ll_pal4_13x5.webp +0 -0
  305. package/dist/lib/image/testdata/webp/ll_photo_40x30.webp +0 -0
  306. package/dist/lib/image/testdata/webp/ll_predictors_64x32.webp +0 -0
  307. package/dist/lib/image/testdata/webp/ly_1x1.webp +0 -0
  308. package/dist/lib/image/testdata/webp/ly_alph_raw_f0_21x13.webp +0 -0
  309. package/dist/lib/image/testdata/webp/ly_alph_raw_f1_21x13.webp +0 -0
  310. package/dist/lib/image/testdata/webp/ly_alph_raw_f2_21x13.webp +0 -0
  311. package/dist/lib/image/testdata/webp/ly_alph_raw_f3_21x13.webp +0 -0
  312. package/dist/lib/image/testdata/webp/ly_alph_vp8l_f0_21x13.webp +0 -0
  313. package/dist/lib/image/testdata/webp/ly_alph_vp8l_f1_21x13.webp +0 -0
  314. package/dist/lib/image/testdata/webp/ly_alph_vp8l_f2_21x13.webp +0 -0
  315. package/dist/lib/image/testdata/webp/ly_alph_vp8l_f3_21x13.webp +0 -0
  316. package/dist/lib/image/testdata/webp/ly_alpha_33x17.webp +0 -0
  317. package/dist/lib/image/testdata/webp/ly_exif_15x11.webp +0 -0
  318. package/dist/lib/image/testdata/webp/ly_i16_96x80.webp +0 -0
  319. package/dist/lib/image/testdata/webp/ly_nofilter_24x20.webp +0 -0
  320. package/dist/lib/image/testdata/webp/ly_odd_17x9.webp +0 -0
  321. package/dist/lib/image/testdata/webp/ly_photo_48x40.webp +0 -0
  322. package/dist/lib/image/testdata/webp/ly_q100_20x18.webp +0 -0
  323. package/dist/lib/image/testdata/webp/ly_sharp_40x36.webp +0 -0
  324. package/dist/lib/image/testdata/webp/ly_simple_filter_33x31.webp +0 -0
  325. package/dist/lib/image/testdata/webp/ly_vpx_parts8_37x45.webp +0 -0
  326. package/dist/lib/image/testdata/webp/ly_vpx_skip_96x80.webp +0 -0
  327. package/dist/lib/image/tests/WebPDecodeTool.rgr +81 -0
  328. package/dist/lib/image/tests/WebPDecoderTest.rgr +1162 -0
  329. package/dist/lib/image/tests/run_webp_tests.sh +39 -0
  330. package/dist/lib/rust/RsJson.rgr +468 -0
  331. package/dist/lib/rust/RsPrelude.rgr +1663 -0
  332. package/dist/lib/shell_test.rgr +7 -7
  333. package/dist/lib/stdlib.rgr +21 -5
  334. package/dist/lib/zip/ranger.json +6 -0
  335. package/dist/rgrc.js +87318 -67610
  336. package/package.json +1010 -1040
  337. package/dist/README.md +0 -117
  338. package/dist/package.json +0 -47
@@ -0,0 +1,1162 @@
1
+ /**
2
+ * tracer/smoke.mjs — does the bitmap tracer page run in a browser?
3
+ *
4
+ * npm run evg:trace:web && npm run evg:trace:web:smoke
5
+ *
6
+ * Checks that the compiled bundle loads, the sample image vectorizes, and the
7
+ * SVG stage ends up with a real <svg>/<path>.
8
+ */
9
+ import fs from "node:fs";
10
+ import path from "node:path";
11
+ import http from "node:http";
12
+ import { fileURLToPath } from "node:url";
13
+ import zlib from "node:zlib";
14
+ import os from "node:os";
15
+ import { createRequire } from "node:module";
16
+
17
+ const HERE = path.dirname(fileURLToPath(import.meta.url));
18
+ const ROOT = path.resolve(HERE, "../../../..");
19
+ const DIST = path.resolve(process.argv[2] || path.join(HERE, "dist"));
20
+
21
+ function loadPlaywright() {
22
+ const anchors = [
23
+ "/opt/node22/lib/node_modules/x",
24
+ path.join(ROOT, "package.json"),
25
+ path.join(process.cwd(), "package.json"),
26
+ import.meta.url,
27
+ ];
28
+ for (const anchor of anchors) {
29
+ for (const name of ["playwright", "playwright-core"]) {
30
+ try {
31
+ return createRequire(anchor)(name);
32
+ } catch { /* next */ }
33
+ }
34
+ }
35
+ return null;
36
+ }
37
+
38
+ function findChrome() {
39
+ const fixed = [process.env.CHROME_PATH, "/usr/bin/chromium", "/usr/bin/google-chrome",
40
+ "/usr/local/bin/google-chrome"].filter(Boolean);
41
+ for (const c of fixed) if (fs.existsSync(c)) return c;
42
+ return null;
43
+ }
44
+
45
+ const pw = loadPlaywright();
46
+ if (!pw) {
47
+ console.log("Playwright is not available — tracer page was not checked.");
48
+ process.exit(0);
49
+ }
50
+ if (!fs.existsSync(path.join(DIST, "index.html")) ||
51
+ !fs.existsSync(path.join(DIST, "evg_bitmap_tracer.js"))) {
52
+ console.error(`no tracer page in ${path.relative(ROOT, DIST)} — run: npm run evg:trace:web`);
53
+ process.exit(1);
54
+ }
55
+
56
+ // A PNG writer, twenty lines of it, so this file can build a picture whose
57
+ // true answer it already knows. Everything else here checks that the tool did
58
+ // something; the composite below checks that what it did was right.
59
+ const CRC = (() => {
60
+ const t = new Int32Array(256);
61
+ for (let n = 0; n < 256; n++) { let c = n;
62
+ for (let k = 0; k < 8; k++) c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1;
63
+ t[n] = c; }
64
+ return t;
65
+ })();
66
+ function crc32(buf) {
67
+ let c = -1;
68
+ for (let i = 0; i < buf.length; i++) c = CRC[(c ^ buf[i]) & 255] ^ (c >>> 8);
69
+ return (c ^ -1) >>> 0;
70
+ }
71
+ function writePng(w, h, rgba) {
72
+ const stride = w * 4 + 1;
73
+ const raw = Buffer.alloc(stride * h);
74
+ for (let y = 0; y < h; y++) rgba.copy(raw, y * stride + 1, y * w * 4, (y + 1) * w * 4);
75
+ const chunk = (type, data) => {
76
+ const len = Buffer.alloc(4); len.writeUInt32BE(data.length, 0);
77
+ const td = Buffer.concat([Buffer.from(type, "ascii"), data]);
78
+ const crc = Buffer.alloc(4); crc.writeUInt32BE(crc32(td), 0);
79
+ return Buffer.concat([len, td, crc]);
80
+ };
81
+ const ihdr = Buffer.alloc(13);
82
+ ihdr.writeUInt32BE(w, 0); ihdr.writeUInt32BE(h, 4);
83
+ ihdr[8] = 8; ihdr[9] = 6;
84
+ return Buffer.concat([Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]),
85
+ chunk("IHDR", ihdr), chunk("IDAT", zlib.deflateSync(raw)),
86
+ chunk("IEND", Buffer.alloc(0))]);
87
+ }
88
+
89
+ const TYPES = {
90
+ ".html": "text/html",
91
+ ".js": "text/javascript",
92
+ ".png": "image/png",
93
+ ".svg": "image/svg+xml",
94
+ };
95
+ const server = http.createServer((req, res) => {
96
+ let file = path.join(DIST, decodeURIComponent(req.url.split("?")[0]));
97
+ if (fs.existsSync(file) && fs.statSync(file).isDirectory()) file = path.join(file, "index.html");
98
+ if (!file.startsWith(DIST) || !fs.existsSync(file)) {
99
+ res.writeHead(404); res.end("missing"); return;
100
+ }
101
+ const ext = path.extname(file);
102
+ res.writeHead(200, { "Content-Type": TYPES[ext] || "application/octet-stream" });
103
+ fs.createReadStream(file).pipe(res);
104
+ });
105
+
106
+ await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve));
107
+ const { port } = server.address();
108
+ const url = `http://127.0.0.1:${port}/`;
109
+
110
+ const { chromium } = pw;
111
+ const browser = await chromium.launch({
112
+ executablePath: findChrome() || undefined,
113
+ headless: true,
114
+ });
115
+ const page = await browser.newPage();
116
+ const errors = [];
117
+ page.on("pageerror", (e) => errors.push(String(e)));
118
+ page.on("console", (msg) => {
119
+ if (msg.type() !== "error") return;
120
+ const t = msg.text();
121
+ if (/favicon\.ico|Failed to load resource.*404/i.test(t)) return;
122
+ errors.push(t);
123
+ });
124
+
125
+ await page.goto(url, { waitUntil: "networkidle" });
126
+ const ready = await page.evaluate(() =>
127
+ typeof EvgBitmapTracer === "function" && typeof ImageBuffer === "function"
128
+ );
129
+ if (!ready) {
130
+ console.error("bundle did not publish EvgBitmapTracer / ImageBuffer");
131
+ process.exitCode = 1;
132
+ } else {
133
+ await page.click("#sample");
134
+ await page.waitForFunction(() => {
135
+ const st = document.getElementById("status");
136
+ return st && /OK/.test(st.textContent || "");
137
+ }, { timeout: 60000 });
138
+ const info = await page.evaluate(() => {
139
+ const out = document.getElementById("outStage");
140
+ const svg = out && out.querySelector("svg");
141
+ const paths = svg ? svg.querySelectorAll("path").length : 0;
142
+ return {
143
+ status: document.getElementById("status").textContent,
144
+ hasSvg: !!svg,
145
+ paths,
146
+ };
147
+ });
148
+ console.log(JSON.stringify(info, null, 2));
149
+ if (!info.hasSvg || info.paths < 1) {
150
+ console.error("expected an SVG with at least one path");
151
+ process.exitCode = 1;
152
+ }
153
+
154
+ // Color mode: the posterize path is the one with the moving parts.
155
+ await page.evaluate(() => {
156
+ document.getElementById("status").textContent = "…";
157
+ const cc = document.getElementById("colorCount");
158
+ cc.value = "8";
159
+ cc.dispatchEvent(new Event("input", { bubbles: true }));
160
+ });
161
+ await page.waitForFunction(() => {
162
+ const st = document.getElementById("status");
163
+ return st && /OK/.test(st.textContent || "");
164
+ }, { timeout: 60000 });
165
+ const color = await page.evaluate(() => {
166
+ const out = document.getElementById("outStage");
167
+ const svg = out && out.querySelector("svg");
168
+ const fills = svg
169
+ ? Array.from(svg.querySelectorAll("path")).map((p) => p.getAttribute("fill"))
170
+ : [];
171
+ return {
172
+ status: document.getElementById("status").textContent,
173
+ paths: fills.length,
174
+ distinctFills: new Set(fills).size,
175
+ swatches: document.querySelectorAll("#palette .sw").length,
176
+ };
177
+ });
178
+ console.log(JSON.stringify(color, null, 2));
179
+ if (color.paths < 2 || color.distinctFills < 2) {
180
+ console.error("expected several colored paths in posterize mode");
181
+ process.exitCode = 1;
182
+ }
183
+ if (color.swatches !== color.paths) {
184
+ console.error("palette swatches should match the emitted layers");
185
+ process.exitCode = 1;
186
+ }
187
+ // The color slider's range is a promise the engine has to keep: it is worth
188
+ // asking for more than sixteen colors only if asking produces more of them.
189
+ // Measured on a portrait the palette keeps growing to 64 (16→15 layers,
190
+ // 32→24, 64→38) and the trace does not get slower for it, so 64 is where the
191
+ // slider stops.
192
+ const wide = await (async () => {
193
+ const top = await page.evaluate(() => +document.getElementById("colorCount").max);
194
+ const layersAt = async (n) => {
195
+ await page.evaluate((n) => {
196
+ document.getElementById("status").textContent = "…";
197
+ const cc = document.getElementById("colorCount");
198
+ cc.value = String(n);
199
+ cc.dispatchEvent(new Event("input", { bubbles: true }));
200
+ }, n);
201
+ await page.waitForFunction(() => /OK/.test(document.getElementById("status").textContent || ""),
202
+ { timeout: 120000 });
203
+ return page.evaluate(() => new Set([...document.querySelectorAll("#outStage svg path")]
204
+ .map((p) => p.getAttribute("fill"))).size);
205
+ };
206
+ return { top, at16: await layersAt(16), atTop: await layersAt(top) };
207
+ })();
208
+ console.log(JSON.stringify(wide));
209
+ if (wide.top < 64) {
210
+ console.error("the color slider should reach 64, stops at " + wide.top);
211
+ process.exitCode = 1;
212
+ }
213
+ if (!(wide.atTop > wide.at16)) {
214
+ console.error("asking for more colors than 16 should produce more of them, got "
215
+ + wide.at16 + " → " + wide.atTop);
216
+ process.exitCode = 1;
217
+ }
218
+
219
+ // The palette controls: a control that is on screen must be one that does
220
+ // something, and none of them may be clipped out of the panel.
221
+ const controls = await page.evaluate(() => {
222
+ const $ = (id) => document.getElementById(id);
223
+ const panel = document.querySelector(".body").getBoundingClientRect();
224
+ const overflow = (id) =>
225
+ Math.round($(id).getBoundingClientRect().right - panel.right);
226
+ const row = $("row-palEdit");
227
+ return {
228
+ autoShowsEditor: getComputedStyle(row).display !== "none",
229
+ autoMarksItIdle: row.classList.contains("pal-inactive"),
230
+ noteShown: !$("palNote").hidden,
231
+ biasOverflow: overflow("paletteBias"),
232
+ modeOverflow: overflow("paletteMode"),
233
+ bgOverflow: overflow("bgMode"),
234
+ };
235
+ });
236
+ console.log(JSON.stringify(controls, null, 2));
237
+ // Hiding the editor on "auto" put "Poimi tuloksesta" out of reach and made
238
+ // the whole group look inert; it stays visible and says it is idle instead.
239
+ if (!controls.autoShowsEditor || !controls.autoMarksItIdle || !controls.noteShown) {
240
+ console.error("the palette editor should be visible and marked idle on auto");
241
+ process.exitCode = 1;
242
+ }
243
+ if (controls.biasOverflow > 0 || controls.modeOverflow > 0 || controls.bgOverflow > 0) {
244
+ console.error("a select is clipped outside the parameter panel");
245
+ process.exitCode = 1;
246
+ }
247
+
248
+ // Editing a color while the mode is "auto" must take effect, not vanish.
249
+ const before = color.paths;
250
+ await page.evaluate(() => {
251
+ document.getElementById("status").textContent = "…";
252
+ const inp = document.querySelector("#palEdit input");
253
+ inp.value = "#ff0055";
254
+ inp.dispatchEvent(new Event("input", { bubbles: true }));
255
+ });
256
+ await page.waitForFunction(() => {
257
+ const st = document.getElementById("status");
258
+ return st && /OK/.test(st.textContent || "");
259
+ }, { timeout: 60000 });
260
+ const edited = await page.evaluate(() => ({
261
+ mode: document.getElementById("paletteMode").value,
262
+ fills: Array.from(document.querySelectorAll("#outStage path"))
263
+ .map((p) => p.getAttribute("fill")),
264
+ }));
265
+ console.log(JSON.stringify(edited));
266
+ if (edited.mode !== "fixed") {
267
+ console.error("editing a color on auto should switch the palette to it");
268
+ process.exitCode = 1;
269
+ }
270
+ if (!edited.fills.includes("#FF0055")) {
271
+ console.error("the edited color should appear in the traced output");
272
+ process.exitCode = 1;
273
+ }
274
+ if (edited.fills.length === before) {
275
+ console.error("the palette change did not reach the output");
276
+ process.exitCode = 1;
277
+ }
278
+
279
+ // Pasting an image loads and traces it; a paste with no image, or one aimed
280
+ // at a text field, is left alone.
281
+ const pasted = await page.evaluate(async () => {
282
+ const cv = document.createElement("canvas");
283
+ cv.width = 40; cv.height = 30;
284
+ const ctx = cv.getContext("2d");
285
+ ctx.fillStyle = "#ffffff"; ctx.fillRect(0, 0, 40, 30);
286
+ ctx.fillStyle = "#101010"; ctx.fillRect(8, 6, 24, 18);
287
+ const blob = await new Promise((r) => cv.toBlob(r, "image/png"));
288
+ const fire = (build) => {
289
+ const dt = new DataTransfer();
290
+ build(dt);
291
+ document.dispatchEvent(new ClipboardEvent("paste", {
292
+ clipboardData: dt, bubbles: true, cancelable: true,
293
+ }));
294
+ };
295
+ document.getElementById("status").textContent = "…";
296
+ fire((dt) => dt.items.add(new File([blob], "pasted.png", { type: "image/png" })));
297
+ await new Promise((r) => setTimeout(r, 1500));
298
+ const afterImage = document.getElementById("status").textContent;
299
+
300
+ // A paste carrying no image must not disturb anything.
301
+ document.getElementById("status").textContent = "UNTOUCHED";
302
+ fire((dt) => dt.setData("text/plain", "just words"));
303
+ await new Promise((r) => setTimeout(r, 400));
304
+ const afterText = document.getElementById("status").textContent;
305
+
306
+ // Nor may it steal a paste meant for a text field.
307
+ const tf = document.createElement("input");
308
+ tf.type = "text";
309
+ document.body.appendChild(tf);
310
+ tf.focus();
311
+ document.getElementById("status").textContent = "UNTOUCHED";
312
+ fire((dt) => dt.items.add(new File([blob], "pasted.png", { type: "image/png" })));
313
+ await new Promise((r) => setTimeout(r, 400));
314
+ const afterField = document.getElementById("status").textContent;
315
+ tf.remove();
316
+
317
+ return { afterImage, afterText, afterField, size: "40x30" };
318
+ });
319
+ console.log(JSON.stringify(pasted));
320
+ if (!/40×30/.test(pasted.afterImage)) {
321
+ console.error("pasting an image should load and trace it");
322
+ process.exitCode = 1;
323
+ }
324
+ if (pasted.afterText !== "UNTOUCHED" || pasted.afterField !== "UNTOUCHED") {
325
+ console.error("paste should be ignored without an image, and inside a text field");
326
+ process.exitCode = 1;
327
+ }
328
+
329
+ // The checkerboard is the only way to tell transparent from a painted
330
+ // background, so it must actually be behind both stages.
331
+ const view = await page.evaluate(() => {
332
+ const src = document.getElementById("srcStage");
333
+ const out = document.getElementById("outStage");
334
+ const on = src.classList.contains("checker") && out.classList.contains("checker");
335
+ const box = document.getElementById("checker");
336
+ box.checked = false;
337
+ box.dispatchEvent(new Event("change", { bubbles: true }));
338
+ const off = src.classList.contains("checker") || out.classList.contains("checker");
339
+ box.checked = true;
340
+ box.dispatchEvent(new Event("change", { bubbles: true }));
341
+ return { on, off, backAgain: out.classList.contains("checker") };
342
+ });
343
+ console.log(JSON.stringify(view));
344
+ if (!view.on || view.off || !view.backAgain) {
345
+ console.error("the checkerboard should be on by default and follow its checkbox");
346
+ process.exitCode = 1;
347
+ }
348
+
349
+ // --- edit mode ----------------------------------------------------------
350
+ // A traced layer is one path per color, so the thing that makes edit mode
351
+ // possible at all is splitting those into one path per shape. Check that,
352
+ // then each of the three tools and undo.
353
+ await page.evaluate(() => {
354
+ // Back to a palette taken from the picture: the test above left the mode on
355
+ // "fixed" with two colors, and a two-color drawing cannot show whether a
356
+ // tool that works on color is doing anything.
357
+ const m = document.getElementById("paletteMode");
358
+ m.value = "auto";
359
+ m.dispatchEvent(new Event("change", { bubbles: true }));
360
+ const c = document.getElementById("colorCount");
361
+ c.value = 8;
362
+ c.dispatchEvent(new Event("input", { bubbles: true }));
363
+ });
364
+ await page.click("#sample");
365
+ await page.waitForFunction(() => !document.getElementById("dl").disabled, { timeout: 120000 });
366
+ // A queued re-trace replaces the drawing and ends the edit session with it,
367
+ // so wait for the tracer to go idle before opening one.
368
+ await page.waitForFunction(() => !document.getElementById("run").disabled, { timeout: 120000 });
369
+ await page.waitForTimeout(300);
370
+
371
+ const edit = await (async () => {
372
+ const before = await page.evaluate(() => document.querySelectorAll("#outStage svg path").length);
373
+ await page.click("#editToggle");
374
+ const exploded = await page.evaluate(() => document.querySelectorAll("#outStage svg path").length);
375
+
376
+ // The status line quotes a shape count and a byte count, and editing
377
+ // changes both — it used to go on quoting the file the tracer produced,
378
+ // which after a few edits describes something that no longer exists.
379
+ await page.waitForTimeout(250);
380
+ const statusLive = await page.evaluate(() => {
381
+ const m = document.getElementById("status").textContent.match(/layers=(\d+).*svg=(\d+)/);
382
+ const live = document.querySelector("#outStage svg");
383
+ return {
384
+ saysLayers: +m[1], saysBytes: +m[2],
385
+ realLayers: live.querySelectorAll("path").length,
386
+ realBytes: new XMLSerializer().serializeToString(live).length
387
+ };
388
+ });
389
+
390
+ // Tarkennin is the default tool, so nothing below can assume which tool is
391
+ // live — every one of them selects itself first.
392
+ const defaultTool = await page.evaluate(() => ({
393
+ refine: document.getElementById("toolRefine").classList.contains("active"),
394
+ merge: document.getElementById("toolMerge").classList.contains("active"),
395
+ stage: document.getElementById("outStage").classList.contains("refining"),
396
+ }));
397
+
398
+ // merge: set the picker, click the biggest shape, count what took the color
399
+ await page.click("#toolMerge");
400
+ await page.evaluate(() => {
401
+ const c = document.getElementById("editColor");
402
+ c.value = "#00ff88";
403
+ c.dispatchEvent(new Event("input", { bubbles: true }));
404
+ const ps = [...document.querySelectorAll("#outStage svg path")];
405
+ ps.sort((a, b) => {
406
+ const x = a.getBBox(), y = b.getBBox();
407
+ return y.width * y.height - x.width * x.height;
408
+ });
409
+ ps[1].dispatchEvent(new MouseEvent("click", { bubbles: true }));
410
+ });
411
+ const green = () => page.evaluate(() =>
412
+ [...document.querySelectorAll("#outStage svg path")]
413
+ .filter(e => (e.getAttribute("fill") || "").toLowerCase() === "#00ff88").length);
414
+ const merged = await green();
415
+ await page.click("#editUndo");
416
+ const undone = await green();
417
+
418
+ // pick: take a shape's own fill into the picker
419
+ await page.click("#toolPick");
420
+ const want = await page.evaluate(() => {
421
+ const el = document.querySelectorAll("#outStage svg path")[2];
422
+ el.dispatchEvent(new MouseEvent("click", { bubbles: true }));
423
+ return (el.getAttribute("fill") || "").toLowerCase();
424
+ });
425
+ const picked = await page.evaluate(() => document.getElementById("editColor").value.toLowerCase());
426
+
427
+ // refine: drag over an area, which re-traces it with a palette of its own
428
+ // and lays the result on top, masked to the stroke. The mask is the part
429
+ // worth guarding — resolved in the wrong coordinate system it hides the
430
+ // whole result, and the tool then reports success while changing nothing.
431
+ await page.click("#toolRefine");
432
+ // Force the patch to differ from what is already there. With the palette
433
+ // seeded from the border and the mask feathered, a refine stroke can quite
434
+ // correctly change nothing — it had nothing to add — and then this test
435
+ // would pass a broken mask as a no-op. Given its own colors to invent and
436
+ // no edge snapping, it must change something, and that is what makes the
437
+ // "is the mask hiding everything" check mean anything.
438
+ await page.evaluate(() => {
439
+ const x = document.getElementById("refineExtra");
440
+ x.value = 12; x.dispatchEvent(new Event("input", { bubbles: true }));
441
+ const e = document.getElementById("edgeBlend");
442
+ e.value = 0; e.dispatchEvent(new Event("input", { bubbles: true }));
443
+ });
444
+ const refined = await page.evaluate(async () => {
445
+ const svg = document.querySelector("#outStage svg");
446
+ const rect = svg.getBoundingClientRect();
447
+ // Rasterize a probe inside the stroke: "one group was added" is not the
448
+ // same claim as "the picture changed", and the mask bug satisfied the
449
+ // first while failing the second.
450
+ const shot = async () => {
451
+ const str = new XMLSerializer().serializeToString(svg);
452
+ const im = new Image();
453
+ im.src = "data:image/svg+xml;base64," + btoa(unescape(encodeURIComponent(str)));
454
+ await im.decode();
455
+ await new Promise(r => setTimeout(r, 150));
456
+ const vb = svg.getAttribute("viewBox").split(/[\s,]+/).map(Number);
457
+ const c = document.createElement("canvas");
458
+ c.width = vb[2]; c.height = vb[3];
459
+ const g = c.getContext("2d");
460
+ g.fillStyle = "#fff"; g.fillRect(0, 0, c.width, c.height);
461
+ g.drawImage(im, 0, 0, c.width, c.height);
462
+ return g.getImageData(0, 0, c.width, c.height).data;
463
+ };
464
+ const vb = svg.getAttribute("viewBox").split(/[\s,]+/).map(Number);
465
+ const before = await shot();
466
+ const stage = document.getElementById("outStage");
467
+ const at = (fx, fy) => ({ x: rect.left + rect.width * fx, y: rect.top + rect.height * fy });
468
+ const send = (type, pt) => stage.dispatchEvent(new PointerEvent(type, {
469
+ bubbles: true, clientX: pt.x, clientY: pt.y
470
+ }));
471
+ send("pointerdown", at(0.3, 0.45));
472
+ for (let i = 1; i <= 12; i++) send("pointermove", at(0.3 + 0.4 * i / 12, 0.45));
473
+ const preview = svg.querySelectorAll('path[stroke="#ff2ec4"]').length;
474
+ send("pointerup", at(0.7, 0.45));
475
+ await new Promise(r => setTimeout(r, 400));
476
+ const after = await shot();
477
+ let changed = 0;
478
+ const y0 = Math.round(vb[3] * 0.40), y1 = Math.round(vb[3] * 0.50);
479
+ const x0 = Math.round(vb[2] * 0.32), x1 = Math.round(vb[2] * 0.68);
480
+ for (let y = y0; y < y1; y++) for (let x = x0; x < x1; x++) {
481
+ const o = (y * vb[2] + x) * 4;
482
+ if (Math.abs(before[o] - after[o]) + Math.abs(before[o+1] - after[o+1])
483
+ + Math.abs(before[o+2] - after[o+2]) > 24) changed++;
484
+ }
485
+ return { preview, previewLeft: svg.querySelectorAll('path[stroke="#ff2ec4"]').length, changed };
486
+ });
487
+ const groups = await page.evaluate(() => document.querySelectorAll("#outStage svg g[mask]").length);
488
+ const masks = await page.evaluate(() => document.querySelectorAll("#outStage svg mask").length);
489
+ await page.click("#editUndo");
490
+ const groupsUndone = await page.evaluate(() => document.querySelectorAll("#outStage svg g[mask]").length);
491
+ const masksUndone = await page.evaluate(() => document.querySelectorAll("#outStage svg mask").length);
492
+
493
+ // smooth: brush over the biggest shape's own colour and check the outline
494
+ // gets simpler rather than heavier, and that undo restores it exactly.
495
+ await page.click("#toolSmooth");
496
+ const smooth = await page.evaluate(async () => {
497
+ const svg = document.querySelector("#outStage svg");
498
+ const ps = [...svg.querySelectorAll("path")];
499
+ ps.sort((a, b) => {
500
+ const x = a.getBBox(), y = b.getBBox();
501
+ return y.width * y.height - x.width * x.height;
502
+ });
503
+ // The biggest shape is usually the background frame; take the biggest one
504
+ // that actually has an outline worth smoothing.
505
+ const el = ps.find(e => (e.getAttribute("d") || "").length > 400) || ps[1];
506
+ el.id = "__smoothTarget";
507
+ const fill = el.getAttribute("fill");
508
+ const c = document.getElementById("editColor");
509
+ c.value = fill; c.dispatchEvent(new Event("input", { bubbles: true }));
510
+ const bb = el.getBBox();
511
+ const rect = svg.getBoundingClientRect();
512
+ const vb = svg.getAttribute("viewBox").split(/[\s,]+/).map(Number);
513
+ const toScreen = (ux, uy) => ({
514
+ x: rect.left + ux / vb[2] * rect.width,
515
+ y: rect.top + uy / vb[3] * rect.height
516
+ });
517
+ const before = el.getAttribute("d").length;
518
+ const stage = document.getElementById("outStage");
519
+ const send = (type, pt) => stage.dispatchEvent(new PointerEvent(type, {
520
+ bubbles: true, clientX: pt.x, clientY: pt.y
521
+ }));
522
+ for (let pass = 0; pass < 3; pass++) {
523
+ send("pointerdown", toScreen(bb.x + bb.width * 0.2, bb.y + bb.height * 0.5));
524
+ for (let i = 1; i <= 12; i++) {
525
+ send("pointermove", toScreen(bb.x + bb.width * (0.2 + 0.6 * i / 12), bb.y + bb.height * 0.5));
526
+ }
527
+ send("pointerup", toScreen(bb.x + bb.width * 0.8, bb.y + bb.height * 0.5));
528
+ }
529
+ return { before, after: el.getAttribute("d").length };
530
+ });
531
+ // A stroke is one undo step however many shapes it crossed, so getting the
532
+ // outline back takes as many clicks as there were strokes that changed
533
+ // something — never one per shape they ran over, which is what recording a
534
+ // shape at a time cost, and which is what this counts.
535
+ let smoothSteps = 0;
536
+ let smoothUndone = -1;
537
+ for (let i = 0; i < 12; i++) {
538
+ const state = await page.evaluate(() => {
539
+ const el = document.getElementById("__smoothTarget");
540
+ return { d: el ? el.getAttribute("d").length : -1,
541
+ empty: document.getElementById("editNote").textContent === "Ei kumottavaa." };
542
+ });
543
+ smoothUndone = state.d;
544
+ if (state.d === smooth.before || state.empty) break;
545
+ await page.click("#editUndo");
546
+ smoothSteps++;
547
+ }
548
+
549
+ // wand: pick an object out and cut the rest away. Four things have to
550
+ // hold. The selection grows with the tolerance — a wand that only ever
551
+ // takes the shape under the cursor is the merge tool with extra steps. A
552
+ // rough outline drawn by hand selects too, and selects something else. The
553
+ // cut must not *uncover* anything: a traced picture is stacked, so a lower
554
+ // layer's shape is the union of itself and everything painted over it, and
555
+ // deleting what is on top used to reveal geometry that was never visible.
556
+ // And all of it comes back in one click.
557
+ await page.click("#toolWand");
558
+ const wand = await (async () => {
559
+ // Seed from a real click point, the way a person does: half the shapes
560
+ // in a stacked drawing are buried under later layers and can neither be
561
+ // clicked nor seen.
562
+ const grow = (tol) => page.evaluate((tol) => {
563
+ const t = document.getElementById("wandTol");
564
+ t.value = String(tol);
565
+ t.dispatchEvent(new Event("input", { bubbles: true }));
566
+ const svg = document.querySelector("#outStage svg");
567
+ const r = svg.getBoundingClientRect();
568
+ const el = document.elementFromPoint(r.left + r.width * 0.5, r.top + r.height * 0.55);
569
+ if (!el || el.tagName !== "path") return -1;
570
+ el.dispatchEvent(new MouseEvent("click", { bubbles: true }));
571
+ return [...svg.querySelectorAll("path:not(.wand-off)")]
572
+ .filter((p) => !p.closest("mask") && !p.closest("clipPath")).length;
573
+ }, tol);
574
+ const tight = await grow(0);
575
+ const loose = await grow(140);
576
+
577
+ // The hand-drawn outline: a wobbling box over the middle of the picture.
578
+ // Nothing is cut along the line — shapes decide, by how much of what you
579
+ // can see of them falls inside it — so the result lands on the picture's
580
+ // own edges.
581
+ const lasso = await page.evaluate(() => {
582
+ const svg = document.querySelector("#outStage svg");
583
+ const r = svg.getBoundingClientRect();
584
+ const stage = document.getElementById("outStage");
585
+ const at = (fx, fy) => ({ x: r.left + r.width * fx, y: r.top + r.height * fy });
586
+ const send = (t, p) => stage.dispatchEvent(new PointerEvent(t, {
587
+ bubbles: true, clientX: p.x, clientY: p.y }));
588
+ const box = [[0.25, 0.2], [0.75, 0.22], [0.78, 0.75], [0.22, 0.72]];
589
+ const pts = [];
590
+ for (let i = 0; i < box.length; i++) {
591
+ const a = box[i], b = box[(i + 1) % box.length];
592
+ for (let k = 0; k < 8; k++) {
593
+ pts.push(at(a[0] + (b[0] - a[0]) * k / 8, a[1] + (b[1] - a[1]) * k / 8));
594
+ }
595
+ }
596
+ send("pointerdown", pts[0]);
597
+ pts.slice(1).forEach((p) => send("pointermove", p));
598
+ send("pointerup", pts[pts.length - 1]);
599
+ window.__smokeLasso = box;
600
+ return {
601
+ sel: [...svg.querySelectorAll("path:not(.wand-off)")]
602
+ .filter((p) => !p.closest("mask") && !p.closest("clipPath")).length,
603
+ left: svg.querySelectorAll('path[stroke="#ff2ec4"]').length,
604
+ // A line drawn through shapes has to clip the ones it cut, or the
605
+ // background reaching across it comes along whole.
606
+ clips: svg.querySelectorAll("clipPath[id^=wandclip]").length,
607
+ clipped: svg.querySelectorAll("path[clip-path]").length,
608
+ };
609
+ });
610
+
611
+ // Rasterize before and after. Every pixel the cut leaves painted has to
612
+ // carry the colour it already had; a pixel that changes colour is
613
+ // geometry that was hidden and is not any more.
614
+ // Both shots are taken in one fixed frame. The cut pulls the frame in,
615
+ // and comparing two rasters of different frames compares pixels that do
616
+ // not stand for the same place — which reads as 9% of the picture having
617
+ // moved when nothing has.
618
+ const shot = (frame) => page.evaluate(async (frame) => {
619
+ const svg = document.querySelector("#outStage svg");
620
+ const vb = frame.split(/[\s,]+/).map(Number);
621
+ const str = new XMLSerializer().serializeToString(svg)
622
+ .replace(/viewBox="[^"]*"/, 'viewBox="' + frame + '"')
623
+ .replace(/width="[^"]*"/, 'width="' + vb[2] + '"')
624
+ .replace(/height="[^"]*"/, 'height="' + vb[3] + '"');
625
+ const im = new Image();
626
+ im.src = "data:image/svg+xml;base64," + btoa(unescape(encodeURIComponent(str)));
627
+ await im.decode();
628
+ await new Promise((r) => setTimeout(r, 120));
629
+ // The <img> renders at the SVG's own width/height, which is its
630
+ // viewBox, so drawing it to fill the canvas maps user space linearly.
631
+ const c = document.createElement("canvas");
632
+ c.width = 240; c.height = Math.max(1, Math.round(240 * vb[3] / vb[2]));
633
+ const g = c.getContext("2d");
634
+ g.drawImage(im, 0, 0, c.width, c.height);
635
+ return { w: c.width, h: c.height, vb,
636
+ px: Array.from(g.getImageData(0, 0, c.width, c.height).data) };
637
+ }, frame);
638
+ const frameBefore = await page.evaluate(() =>
639
+ document.querySelector("#outStage svg").getAttribute("viewBox"));
640
+ const before = await shot(frameBefore);
641
+ // Shapes of the drawing: not the ones the tool parks inside a <mask> or
642
+ // a <clipPath>, which are machinery rather than picture.
643
+ const drawn = () => page.evaluate(() =>
644
+ [...document.querySelectorAll("#outStage svg path")]
645
+ .filter((p) => !p.closest("mask") && !p.closest("clipPath")).length);
646
+ const shapesBefore = await drawn();
647
+ await page.click("#wandCut");
648
+ const cut = await page.evaluate(() => {
649
+ const svg = document.querySelector("#outStage svg");
650
+ return {
651
+ // Paths inside the mask are not shapes of the drawing: the cut moves
652
+ // what it removes in there rather than cloning it.
653
+ shapes: [...svg.querySelectorAll("path")]
654
+ .filter((p) => !p.closest("mask") && !p.closest("clipPath")).length,
655
+ frame: svg.getAttribute("viewBox"),
656
+ marked: svg.querySelectorAll(".wand-off").length,
657
+ masks: svg.querySelectorAll("mask[id^=wandmask]").length,
658
+ };
659
+ });
660
+ const after = await shot(frameBefore);
661
+ // Interior pixels only. A kept shape's edge used to be composited
662
+ // against the neighbour that has just been removed and is now composited
663
+ // against nothing, so the one-pixel antialiased fringe legitimately
664
+ // changes colour all over the drawing — 7% of it, which is noise, not
665
+ // uncovering. Uncovered geometry is area, and area has an interior.
666
+ let painted = 0, moved = 0;
667
+ for (let y = 1; y + 1 < after.h; y++) {
668
+ for (let x = 1; x + 1 < after.w; x++) {
669
+ const o = (y * after.w + x) * 4;
670
+ if (after.px[o + 3] < 255) continue;
671
+ if (after.px[o - 4 + 3] < 255 || after.px[o + 4 + 3] < 255
672
+ || after.px[o - after.w * 4 + 3] < 255
673
+ || after.px[o + after.w * 4 + 3] < 255) continue;
674
+ painted++;
675
+ if (Math.abs(before.px[o] - after.px[o])
676
+ + Math.abs(before.px[o + 1] - after.px[o + 1])
677
+ + Math.abs(before.px[o + 2] - after.px[o + 2]) > 60) moved++;
678
+ }
679
+ }
680
+ // How far past the drawn line the result reaches. Taking every admitted
681
+ // shape whole let one background shape crossing the line drag its whole
682
+ // visible area in, and the enclosure rule then closed over everything
683
+ // that shape surrounded — the flag, the curtain and the desk came along
684
+ // with the person. A shape the line cuts through is clipped to it now,
685
+ // so what sticks out is only the overhang of shapes that were almost
686
+ // entirely inside anyway.
687
+ const leak = await page.evaluate((box) => {
688
+ const svg = document.querySelector("#outStage svg");
689
+ const vb = svg.getAttribute("viewBox").split(/[\s,]+/).map(Number);
690
+ const c = document.createElement("canvas");
691
+ c.width = 200; c.height = Math.max(1, Math.round(200 * vb[3] / vb[2]));
692
+ const g = c.getContext("2d");
693
+ // The lasso, in the frame's own coordinates. It was drawn as fractions
694
+ // of the original 320×221 stage.
695
+ const poly = box.map((p) => [p[0] * 320, p[1] * 221]);
696
+ const inPoly = (x, y) => { let inside = false;
697
+ for (let i = 0, j = poly.length - 1; i < poly.length; j = i++) {
698
+ const [xi, yi] = poly[i], [xj, yj] = poly[j];
699
+ if (((yi > y) !== (yj > y)) && (x < (xj - xi) * (y - yi) / (yj - yi) + xi)) {
700
+ inside = !inside;
701
+ }
702
+ }
703
+ return inside; };
704
+ return new Promise((done) => {
705
+ const im = new Image();
706
+ im.onload = () => {
707
+ g.drawImage(im, 0, 0, c.width, c.height);
708
+ const d = g.getImageData(0, 0, c.width, c.height).data;
709
+ let painted = 0, outside = 0;
710
+ for (let y = 0; y < c.height; y++) {
711
+ for (let x = 0; x < c.width; x++) {
712
+ if (d[(y * c.width + x) * 4 + 3] < 200) continue;
713
+ painted++;
714
+ const ux = vb[0] + (x + 0.5) * vb[2] / c.width;
715
+ const uy = vb[1] + (y + 0.5) * vb[3] / c.height;
716
+ if (!inPoly(ux, uy)) outside++;
717
+ }
718
+ }
719
+ done({ painted, outside });
720
+ };
721
+ im.src = "data:image/svg+xml;base64,"
722
+ + btoa(unescape(encodeURIComponent(new XMLSerializer().serializeToString(svg))));
723
+ });
724
+ }, await page.evaluate(() => window.__smokeLasso));
725
+
726
+ await page.click("#editUndo");
727
+ const back = await page.evaluate(() => {
728
+ const svg = document.querySelector("#outStage svg");
729
+ return { shapes: [...svg.querySelectorAll("path")]
730
+ .filter((p) => !p.closest("mask") && !p.closest("clipPath")).length,
731
+ frame: svg.getAttribute("viewBox"),
732
+ masks: svg.querySelectorAll("mask[id^=wandmask]").length,
733
+ clips: svg.querySelectorAll("clipPath[id^=wandclip]").length };
734
+ });
735
+ return { tight, loose, lasso, shapesBefore, frameBefore, cut, back, painted, moved, leak };
736
+ })();
737
+ await page.click("#toolRefine");
738
+
739
+ // The refine-size slider must not re-trace: a re-trace replaces the
740
+ // drawing and takes the edit session down with it, which is a strange way
741
+ // to lose your work while sizing the next click.
742
+ await page.evaluate(() => {
743
+ const r = document.getElementById("refineSize");
744
+ r.value = 80;
745
+ r.dispatchEvent(new Event("input", { bubbles: true }));
746
+ });
747
+ await page.waitForTimeout(400);
748
+ const stillEditing = await page.evaluate(() =>
749
+ document.getElementById("editbar").classList.contains("on"));
750
+ return { before, exploded, statusLive, defaultTool, merged, undone, picked, want, wand, smoothSteps,
751
+ groups, groupsUndone, stillEditing,
752
+ masks, masksUndone, preview: refined.preview, previewLeft: refined.previewLeft,
753
+ refineChangedPixels: refined.changed,
754
+ smoothBefore: smooth.before, smoothAfter: smooth.after, smoothUndone };
755
+ })();
756
+ console.log(JSON.stringify(edit));
757
+ if (!(edit.exploded > edit.before)) {
758
+ console.error("edit mode must split color layers into individual shapes");
759
+ process.exitCode = 1;
760
+ }
761
+ if (edit.statusLive.saysLayers !== edit.statusLive.realLayers
762
+ || edit.statusLive.saysBytes !== edit.statusLive.realBytes) {
763
+ console.error("the status line must describe the drawing on screen, got "
764
+ + JSON.stringify(edit.statusLive));
765
+ process.exitCode = 1;
766
+ }
767
+ if (edit.merged !== 1 || edit.undone !== 0) {
768
+ console.error("merge should recolor exactly one shape, and undo should put it back");
769
+ process.exitCode = 1;
770
+ }
771
+ if (edit.picked !== edit.want) {
772
+ console.error("the picker should take the clicked shape's fill, got " + edit.picked + " want " + edit.want);
773
+ process.exitCode = 1;
774
+ }
775
+ if (edit.groups !== 1 || edit.groupsUndone !== 0 || edit.masks !== 1 || edit.masksUndone !== 0) {
776
+ console.error("a refine stroke should add one masked group and its mask, and undo should take both away");
777
+ process.exitCode = 1;
778
+ }
779
+ if (!(edit.refineChangedPixels > 0)) {
780
+ console.error("a refine stroke must change the picture under it, not just add a hidden group");
781
+ process.exitCode = 1;
782
+ }
783
+ if (edit.preview !== 1 || edit.previewLeft !== 0) {
784
+ console.error("the stroke preview should show while dragging and be gone afterwards");
785
+ process.exitCode = 1;
786
+ }
787
+ if (!(edit.smoothAfter > 0) || edit.smoothAfter === edit.smoothBefore) {
788
+ console.error("the smooth brush should change the outline it is dragged over");
789
+ process.exitCode = 1;
790
+ }
791
+ if (edit.smoothUndone !== edit.smoothBefore) {
792
+ console.error("undo should restore the smoothed outline exactly, got "
793
+ + edit.smoothUndone + " want " + edit.smoothBefore);
794
+ process.exitCode = 1;
795
+ }
796
+ if (edit.smoothSteps !== 3) {
797
+ console.error("three smooth strokes should take exactly three clicks back, took "
798
+ + edit.smoothSteps);
799
+ process.exitCode = 1;
800
+ }
801
+ if (!edit.stillEditing) {
802
+ console.error("sizing the refine box must not re-trace and drop the edit session");
803
+ process.exitCode = 1;
804
+ }
805
+ if (!edit.defaultTool.refine || edit.defaultTool.merge || !edit.defaultTool.stage) {
806
+ console.error("edit mode should open on Tarkennin, got " + JSON.stringify(edit.defaultTool));
807
+ process.exitCode = 1;
808
+ }
809
+ if (!(edit.wand.loose > edit.wand.tight) || edit.wand.tight < 1) {
810
+ console.error("the wand selection should grow with its tolerance, got "
811
+ + edit.wand.tight + " → " + edit.wand.loose);
812
+ process.exitCode = 1;
813
+ }
814
+ if (edit.wand.lasso.sel < 2 || edit.wand.lasso.sel === edit.wand.loose
815
+ || edit.wand.lasso.left !== 0 || edit.wand.lasso.clips !== 1
816
+ || edit.wand.lasso.clipped < 1) {
817
+ console.error("a hand-drawn outline should select an area of its own and clean up after itself, got "
818
+ + JSON.stringify(edit.wand.lasso));
819
+ process.exitCode = 1;
820
+ }
821
+ if (edit.wand.cut.shapes !== edit.wand.lasso.sel || edit.wand.cut.marked !== 0) {
822
+ console.error("the cut should leave exactly the selection behind, got "
823
+ + JSON.stringify(edit.wand.cut) + " want " + edit.wand.lasso.sel);
824
+ process.exitCode = 1;
825
+ }
826
+ if (!(edit.wand.cut.shapes < edit.wand.shapesBefore)
827
+ || edit.wand.cut.frame === edit.wand.frameBefore) {
828
+ console.error("the cut should drop the rest of the drawing and pull the frame in, got "
829
+ + JSON.stringify(edit.wand.cut));
830
+ process.exitCode = 1;
831
+ }
832
+ // The one that catches the stacking bug: cutting a box out of a picture with
833
+ // a ball over it returned the box with the ball's bulge painted blue, and
834
+ // the tool reported success either way.
835
+ if (!(edit.wand.painted > 500) || edit.wand.moved > edit.wand.painted * 0.005) {
836
+ console.error("the cut must not uncover hidden geometry: " + edit.wand.moved
837
+ + " of " + edit.wand.painted + " painted pixels changed colour");
838
+ process.exitCode = 1;
839
+ }
840
+ if (!(edit.wand.leak.painted > 500)
841
+ || edit.wand.leak.outside > edit.wand.leak.painted * 0.05) {
842
+ console.error("a hand-drawn outline should bound the cut: " + edit.wand.leak.outside
843
+ + " of " + edit.wand.leak.painted + " painted pixels landed outside the line");
844
+ process.exitCode = 1;
845
+ }
846
+ if (edit.wand.back.shapes !== edit.wand.shapesBefore
847
+ || edit.wand.back.frame !== edit.wand.frameBefore || edit.wand.back.masks !== 0
848
+ || edit.wand.back.clips !== 0) {
849
+ console.error("undo should put every cut shape back and take the mask, the clip and the frame with it, got "
850
+ + JSON.stringify(edit.wand.back) + " want " + edit.wand.shapesBefore
851
+ + " / " + edit.wand.frameBefore);
852
+ process.exitCode = 1;
853
+ }
854
+
855
+ // --- pre-processing ------------------------------------------------------
856
+ // Filters that run on the bitmap before anything is quantized. The one that
857
+ // has to hold is that they reach the tracer at all: the chain is built in the
858
+ // page and the engine only ever sees its output, so a wiring mistake is
859
+ // invisible except by tracing the same image twice.
860
+ const pre = await (async () => {
861
+ // Leave edit mode and re-trace first: the status quotes the live drawing,
862
+ // and the edit tests above left a modified one, so "restores exactly" would
863
+ // be comparing an edited picture against a fresh trace.
864
+ if (await page.evaluate(() => document.getElementById("editbar").classList.contains("on"))) {
865
+ await page.click("#editToggle");
866
+ }
867
+ await page.click("#run");
868
+ await page.waitForFunction(() => !document.getElementById("run").disabled, { timeout: 120000 });
869
+ await page.waitForTimeout(350);
870
+
871
+ const hiddenAtFirst = await page.evaluate(() =>
872
+ !document.getElementById("prebar").classList.contains("on"));
873
+ await page.click("#preToggle");
874
+ const opens = await page.evaluate(() =>
875
+ document.getElementById("prebar").classList.contains("on"));
876
+
877
+ const bytes = () => page.evaluate(() => {
878
+ const m = document.getElementById("status").textContent.match(/svg=(\d+)/);
879
+ return m ? +m[1] : -1;
880
+ });
881
+ const settle = async () => {
882
+ await page.waitForFunction(() => !document.getElementById("run").disabled, { timeout: 120000 });
883
+ await page.waitForTimeout(350);
884
+ };
885
+ await settle();
886
+ const plain = await bytes();
887
+
888
+ // Blur is the one that must visibly simplify: it decides how much detail
889
+ // reaches the tracer at all.
890
+ // input redraws, change is what a released slider sends and what the undo
891
+ // stack records — a real drag sends both.
892
+ await page.evaluate(() => {
893
+ const e = document.getElementById("preBlur");
894
+ e.value = 4;
895
+ e.dispatchEvent(new Event("input", { bubbles: true }));
896
+ e.dispatchEvent(new Event("change", { bubbles: true }));
897
+ });
898
+ await settle();
899
+ const blurred = await bytes();
900
+ const undoLive = await page.evaluate(() => !document.getElementById("preUndo").disabled);
901
+
902
+ // A second change, then step back over it: undo works on the settings, so
903
+ // one step has to land exactly on the previous set of them.
904
+ await page.evaluate(() => {
905
+ const e = document.getElementById("preContrast");
906
+ e.value = 50;
907
+ e.dispatchEvent(new Event("input", { bubbles: true }));
908
+ e.dispatchEvent(new Event("change", { bubbles: true }));
909
+ });
910
+ await settle();
911
+ await page.click("#preUndo");
912
+ await settle();
913
+ const undone = await bytes();
914
+
915
+ // And restoring must put the original picture back exactly.
916
+ await page.click("#preReset");
917
+ await settle();
918
+ const restored = await bytes();
919
+ // One picture, not an original and a copy of it: two panes, never three.
920
+ const panes = await page.evaluate(() =>
921
+ document.querySelectorAll(".split > .pane").length);
922
+ return { hiddenAtFirst, opens, plain, blurred, undoLive, undone, restored, panes };
923
+ })();
924
+ console.log(JSON.stringify(pre));
925
+ if (!pre.hiddenAtFirst || !pre.opens) {
926
+ console.error("pre-processing should be hidden until asked for, then open");
927
+ process.exitCode = 1;
928
+ }
929
+ if (!(pre.blurred < pre.plain)) {
930
+ console.error("blurring the bitmap should reach the tracer and simplify it, got "
931
+ + pre.plain + " -> " + pre.blurred);
932
+ process.exitCode = 1;
933
+ }
934
+ if (pre.restored !== pre.plain) {
935
+ console.error("restoring should bring the original trace back exactly, got "
936
+ + pre.restored + " want " + pre.plain);
937
+ process.exitCode = 1;
938
+ }
939
+ if (!pre.undoLive || pre.undone !== pre.blurred) {
940
+ console.error("undo should step back onto the previous settings exactly, got "
941
+ + pre.undone + " want " + pre.blurred);
942
+ process.exitCode = 1;
943
+ }
944
+ if (pre.panes !== 2) {
945
+ console.error("the picture is one picture: source and SVG, never a third pane, got "
946
+ + pre.panes);
947
+ process.exitCode = 1;
948
+ }
949
+
950
+ // --- the refiner must read the edited picture, not the original -----------
951
+ // Pre-processing edits the bitmap; everything that samples pixels afterwards
952
+ // has to sample the edited one. The refiner did not, so on a blurred picture
953
+ // it cut its patch from the unblurred original and put sharp detail back
954
+ // exactly where the filters had been asked to take it away.
955
+ const refPre = await (async () => {
956
+ const settle = async () => {
957
+ await page.waitForFunction(() => !document.getElementById("run").disabled, { timeout: 120000 });
958
+ await page.waitForTimeout(350);
959
+ };
960
+ if (await page.evaluate(() => document.getElementById("editbar").classList.contains("on"))) {
961
+ await page.click("#editToggle");
962
+ }
963
+ await page.evaluate(() => {
964
+ const e = document.getElementById("preBlur");
965
+ e.value = 6;
966
+ e.dispatchEvent(new Event("input", { bubbles: true }));
967
+ e.dispatchEvent(new Event("change", { bubbles: true }));
968
+ });
969
+ await settle();
970
+
971
+ const energy = () => page.evaluate(async () => {
972
+ const svg = document.querySelector("#outStage svg");
973
+ const str = new XMLSerializer().serializeToString(svg);
974
+ const im = new Image();
975
+ im.src = "data:image/svg+xml;base64," + btoa(unescape(encodeURIComponent(str)));
976
+ await im.decode();
977
+ await new Promise(r => setTimeout(r, 200));
978
+ const vb = svg.getAttribute("viewBox").split(/[\s,]+/).map(Number);
979
+ const c = document.createElement("canvas");
980
+ c.width = vb[2]; c.height = vb[3];
981
+ const g = c.getContext("2d");
982
+ g.fillStyle = "#fff"; g.fillRect(0, 0, c.width, c.height);
983
+ g.drawImage(im, 0, 0, c.width, c.height);
984
+ const d = g.getImageData(0, 0, c.width, c.height).data;
985
+ const y0 = Math.round(vb[3] * 0.40), y1 = Math.round(vb[3] * 0.50);
986
+ const x0 = Math.round(vb[2] * 0.32), x1 = Math.round(vb[2] * 0.66);
987
+ let sum = 0, n = 0;
988
+ for (let y = y0; y < y1; y++) for (let x = x0; x < x1; x++) {
989
+ const o = (y * vb[2] + x) * 4, q = (y * vb[2] + x + 1) * 4;
990
+ sum += Math.abs(d[o] - d[q]) + Math.abs(d[o+1] - d[q+1]) + Math.abs(d[o+2] - d[q+2]);
991
+ n++;
992
+ }
993
+ return sum / n / 3;
994
+ });
995
+ const flat = await energy();
996
+
997
+ await page.click("#editToggle");
998
+ await page.click("#toolRefine");
999
+ await page.evaluate(async () => {
1000
+ const svg = document.querySelector("#outStage svg");
1001
+ const rect = svg.getBoundingClientRect();
1002
+ const stage = document.getElementById("outStage");
1003
+ const at = (fx, fy) => ({ x: rect.left + rect.width * fx, y: rect.top + rect.height * fy });
1004
+ const send = (t, pt) => stage.dispatchEvent(new PointerEvent(t, {
1005
+ bubbles: true, clientX: pt.x, clientY: pt.y
1006
+ }));
1007
+ send("pointerdown", at(0.34, 0.45));
1008
+ for (let i = 1; i <= 12; i++) send("pointermove", at(0.34 + 0.3 * i / 12, 0.45));
1009
+ send("pointerup", at(0.64, 0.45));
1010
+ await new Promise(r => setTimeout(r, 400));
1011
+ });
1012
+ const refined = await energy();
1013
+ return { flat: +flat.toFixed(2), refined: +refined.toFixed(2) };
1014
+ })();
1015
+ console.log(JSON.stringify(refPre));
1016
+ // Reading the original instead measured 3.4x on a portrait; reading the
1017
+ // edited picture measured 1.4x. Anything past double is the old bug back.
1018
+ if (refPre.refined > refPre.flat * 2) {
1019
+ console.error("the refiner is sampling the unprocessed bitmap: edge energy went "
1020
+ + refPre.flat + " -> " + refPre.refined);
1021
+ process.exitCode = 1;
1022
+ }
1023
+
1024
+ // --- can it tell two pictures apart? --------------------------------------
1025
+ // Every check above asks whether the tool did something. This one asks
1026
+ // whether what it did was right, and it can, because the picture was built
1027
+ // here: one background, one figure, composited through a silhouette this
1028
+ // file knows by heart. The wand is then given a deliberately sloppy version
1029
+ // of that silhouette — every point pushed out or in by up to 18 px — and
1030
+ // what it cuts is compared against the truth, pixel by pixel.
1031
+ const cut = await (async () => {
1032
+ const W = 240, H = 300;
1033
+ const px = Buffer.alloc(W * H * 4);
1034
+ // Ground truth: a head on a body, as a function rather than a bitmap.
1035
+ const inFigure = (x, y) => {
1036
+ const hx = x - 120, hy = y - 78;
1037
+ if (hx * hx / (34 * 34) + hy * hy / (40 * 40) <= 1) return true;
1038
+ return y >= 108 && y <= 285 && Math.abs(x - 120) <= 62 - Math.max(0, (140 - y) * 0.4);
1039
+ };
1040
+ for (let y = 0; y < H; y++) {
1041
+ for (let x = 0; x < W; x++) {
1042
+ const o = (y * W + x) * 4;
1043
+ let r, g, b;
1044
+ if (inFigure(x, y)) {
1045
+ // The figure: warm, banded, with one cool patch so it is not a
1046
+ // single flat tone the color flood could have had for free.
1047
+ const band = ((x * 0.7 + y * 1.3) | 0) % 60;
1048
+ r = 150 + band; g = 90 + (band >> 1); b = 60 + (band >> 2);
1049
+ if (x > 96 && x < 144 && y > 150 && y < 200) { r = 70; g = 90; b = 140; }
1050
+ } else {
1051
+ // The background: cool, banded, with warm streaks, so neither side
1052
+ // owns a colour outright.
1053
+ const band = ((y * 1.1 + x * 0.3) | 0) % 50;
1054
+ r = 60 + band; g = 80 + band; b = 120 + band;
1055
+ if ((x * 3 + y) % 47 < 3) { r = 150; g = 100; b = 70; }
1056
+ }
1057
+ px[o] = r; px[o + 1] = g; px[o + 2] = b; px[o + 3] = 255;
1058
+ }
1059
+ }
1060
+ const file = path.join(os.tmpdir(), "tracer-smoke-composite.png");
1061
+ fs.writeFileSync(file, writePng(W, H, px));
1062
+
1063
+ await page.setInputFiles("#file", file);
1064
+ await page.waitForFunction(() =>
1065
+ /OK/.test(document.getElementById("status").textContent || ""), { timeout: 120000 });
1066
+ await page.evaluate(() => {
1067
+ document.getElementById("status").textContent = "…";
1068
+ const c = document.getElementById("colorCount");
1069
+ c.value = 12; c.dispatchEvent(new Event("input", { bubbles: true }));
1070
+ });
1071
+ await page.waitForFunction(() =>
1072
+ /OK/.test(document.getElementById("status").textContent || ""), { timeout: 120000 });
1073
+ await page.click("#editToggle");
1074
+ await page.click("#toolWand");
1075
+ const r = await page.evaluate(async ({ W, H }) => {
1076
+ const inFigure = (x, y) => {
1077
+ const hx = x - 120, hy = y - 78;
1078
+ if (hx * hx / (34 * 34) + hy * hy / (40 * 40) <= 1) return true;
1079
+ return y >= 108 && y <= 285 && Math.abs(x - 120) <= 62 - Math.max(0, (140 - y) * 0.4);
1080
+ };
1081
+ const svg = document.querySelector("#outStage svg");
1082
+ const vb0 = svg.getAttribute("viewBox");
1083
+ const rect = svg.getBoundingClientRect();
1084
+ const stage = document.getElementById("outStage");
1085
+ const at = (ux, uy) => ({ x: rect.left + ux / W * rect.width,
1086
+ y: rect.top + uy / H * rect.height });
1087
+ const send = (t, p) => stage.dispatchEvent(new PointerEvent(t, {
1088
+ bubbles: true, clientX: p.x, clientY: p.y }));
1089
+ // Walk the silhouette and push every point away from the middle by a
1090
+ // wobble: this is the hand, and it is not steady.
1091
+ const pts = [];
1092
+ for (let i = 0; i < 72; i++) {
1093
+ const th = i / 72 * Math.PI * 2;
1094
+ const dx = Math.cos(th), dy = Math.sin(th);
1095
+ let hit = 0;
1096
+ for (let t = 4; t < 260; t += 1.5) {
1097
+ if (inFigure(120 + dx * t, 165 + dy * t)) hit = t;
1098
+ }
1099
+ if (!hit) continue;
1100
+ const wobble = 18 * Math.sin(i * 0.8) + 6;
1101
+ pts.push(at(120 + dx * (hit + wobble), 165 + dy * (hit + wobble)));
1102
+ }
1103
+ send("pointerdown", pts[0]);
1104
+ pts.slice(1).forEach((p) => send("pointermove", p));
1105
+ send("pointerup", pts[0]);
1106
+ const note = document.getElementById("editNote").textContent;
1107
+ document.getElementById("wandCut").click();
1108
+ const str = new XMLSerializer().serializeToString(svg)
1109
+ .replace(/viewBox="[^"]*"/, 'viewBox="' + vb0 + '"')
1110
+ .replace(/width="[^"]*"/, 'width="' + W + '"')
1111
+ .replace(/height="[^"]*"/, 'height="' + H + '"');
1112
+ const im = new Image();
1113
+ im.src = "data:image/svg+xml;base64," + btoa(unescape(encodeURIComponent(str)));
1114
+ await im.decode();
1115
+ await new Promise((r) => setTimeout(r, 150));
1116
+ const c = document.createElement("canvas");
1117
+ c.width = W; c.height = H;
1118
+ const g = c.getContext("2d");
1119
+ g.drawImage(im, 0, 0, W, H);
1120
+ const d = g.getImageData(0, 0, W, H).data;
1121
+ let tp = 0, fp = 0, fn = 0;
1122
+ for (let y = 0; y < H; y++) {
1123
+ for (let x = 0; x < W; x++) {
1124
+ const got = d[(y * W + x) * 4 + 3] > 128, want = inFigure(x, y);
1125
+ if (got && want) tp++; else if (got) fp++; else if (want) fn++;
1126
+ }
1127
+ }
1128
+ return { note, tp, fp, fn,
1129
+ iou: +(tp / (tp + fp + fn)).toFixed(3),
1130
+ precision: +(tp / (tp + fp)).toFixed(3),
1131
+ recall: +(tp / (tp + fn)).toFixed(3) };
1132
+ }, { W, H });
1133
+ return r;
1134
+ })();
1135
+ console.log(JSON.stringify(cut));
1136
+ // Measured on three composites of two real photographs, a rough outline
1137
+ // scores 0.86 to 0.98 against the truth; this generated one sits in the same
1138
+ // range. The bar is set below the worst of them, so it fails on a change
1139
+ // that makes the cut worse rather than on the noise of a wobbling line.
1140
+ if (!(cut.iou >= 0.8)) {
1141
+ console.error("a rough outline should cut the figure out of the background, IoU "
1142
+ + cut.iou + " (precision " + cut.precision + ", recall " + cut.recall + ")");
1143
+ process.exitCode = 1;
1144
+ }
1145
+ if (!(cut.recall >= 0.85)) {
1146
+ console.error("the cut should keep the figure, recall " + cut.recall);
1147
+ process.exitCode = 1;
1148
+ }
1149
+
1150
+ if (!process.exitCode) {
1151
+ console.log("tracer smoke OK");
1152
+ }
1153
+ }
1154
+
1155
+ if (errors.length) {
1156
+ console.error("page errors:\n" + errors.join("\n"));
1157
+ process.exitCode = 1;
1158
+ }
1159
+
1160
+ await browser.close();
1161
+ server.close();
1162
+ process.exit(process.exitCode || 0);