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,2805 @@
1
+ <!doctype html>
2
+ <html lang="fi">
3
+ <head>
4
+ <meta charset="utf-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1">
6
+ <title>EVG Bitmap Tracer — bitmap → SVG</title>
7
+ <meta name="description" content="Lataa JPEG/PNG, säädä Potrace-tyylisiä parametreja ja vektoroi kuva selaimessa. Ei palvelinta — käännetty Ranger EvgBitmapTracer.">
8
+ <style>
9
+ :root {
10
+ --bg: #12151b; --panel: #171b23; --line: #2a3040; --ink: #e8ecf4;
11
+ --muted: #97a1b4; --accent: #7db3ff; --bad: #ff9a8f; --ok: #8fd39a;
12
+ --stage: #f4f1ea; --mono: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
13
+ }
14
+ * { box-sizing: border-box; }
15
+ body {
16
+ margin: 0; background: var(--bg); color: var(--ink);
17
+ font: 15px/1.5 system-ui, -apple-system, "Segoe UI", sans-serif;
18
+ }
19
+ header { padding: 28px 24px 12px; max-width: 1280px; margin: 0 auto; }
20
+ h1 { margin: 0 0 6px; font-size: 26px; font-weight: 650; letter-spacing: -0.01em; }
21
+ header p { margin: 0; color: var(--muted); max-width: 72ch; }
22
+ header a { color: var(--accent); }
23
+ .crumb { margin: 0 0 10px; font-size: 12px; color: var(--muted); }
24
+ .crumb a { color: var(--muted); text-decoration: none; }
25
+ .crumb a:hover { color: var(--accent); }
26
+
27
+ main {
28
+ max-width: 1280px; margin: 0 auto; padding: 8px 24px 64px;
29
+ display: grid; grid-template-columns: minmax(280px, 340px) minmax(0, 1fr); gap: 18px;
30
+ }
31
+ @media (max-width: 900px) {
32
+ main { grid-template-columns: minmax(0, 1fr); padding: 8px 14px 48px; }
33
+ }
34
+
35
+ section {
36
+ background: var(--panel); border: 1px solid var(--line); border-radius: 10px; overflow: hidden;
37
+ }
38
+ .head {
39
+ display: flex; align-items: center; gap: 10px; flex-wrap: wrap;
40
+ padding: 10px 14px; border-bottom: 1px solid var(--line);
41
+ font-size: 12px; text-transform: uppercase; letter-spacing: 0.08em; color: var(--muted);
42
+ }
43
+ .head .grow { flex: 1; }
44
+ .body { padding: 14px; }
45
+
46
+ label.row {
47
+ display: grid; grid-template-columns: 1fr auto; gap: 6px 10px;
48
+ align-items: center; margin: 0 0 12px; font-size: 13px;
49
+ }
50
+ label.row span.hint { grid-column: 1 / -1; color: var(--muted); font-size: 11.5px; margin-top: -6px; }
51
+ label.row input[type="range"] { width: 100%; grid-column: 1 / -1; }
52
+ /* A select sized by its widest option pushes out of the 300px panel and the
53
+ text is then clipped, so give it the full row like the sliders have. */
54
+ label.row select { grid-column: 1 / -1; width: 100%; min-width: 0; box-sizing: border-box; }
55
+ label.check { display: flex; align-items: center; gap: 8px; margin: 0 0 12px; font-size: 13px; }
56
+ select, input[type="number"], input[type="color"], input[type="text"] {
57
+ font: inherit; font-size: 13px; color: var(--ink); background: #202634;
58
+ border: 1px solid var(--line); border-radius: 6px; padding: 4px 8px;
59
+ }
60
+ input[type="color"] { padding: 0; width: 42px; height: 28px; }
61
+ .val { font-family: var(--mono); font-size: 12px; color: var(--accent); min-width: 3.5ch; text-align: right; }
62
+
63
+ button, .filebtn {
64
+ font: inherit; font-size: 13px; color: var(--ink); background: #202634;
65
+ border: 1px solid var(--line); border-radius: 999px; padding: 6px 14px; cursor: pointer;
66
+ }
67
+ button:hover, .filebtn:hover { border-color: var(--accent); color: var(--accent); }
68
+ button.primary { background: var(--accent); border-color: var(--accent); color: #10131a; font-weight: 600; }
69
+ button.primary:hover { filter: brightness(1.05); color: #10131a; }
70
+ button:disabled { opacity: 0.45; cursor: default; }
71
+ .filebtn { display: inline-block; }
72
+ .filebtn input { display: none; }
73
+ .actions { display: flex; flex-wrap: wrap; gap: 8px; margin-top: 4px; }
74
+
75
+ .stage {
76
+ background: var(--stage); min-height: 280px; display: flex; align-items: center; justify-content: center;
77
+ padding: 16px; overflow: auto;
78
+ }
79
+ .stage img, .stage svg { max-width: 100%; height: auto; display: block; }
80
+ /* Transparency needs something behind it to read against; the cream stage
81
+ looks exactly like a painted cream background. */
82
+ .stage.checker {
83
+ background-color: #fbfbfb;
84
+ background-image:
85
+ linear-gradient(45deg, #cfcfcf 25%, transparent 25%, transparent 75%, #cfcfcf 75%),
86
+ linear-gradient(45deg, #cfcfcf 25%, transparent 25%, transparent 75%, #cfcfcf 75%);
87
+ background-size: 18px 18px;
88
+ background-position: 0 0, 9px 9px;
89
+ }
90
+ .stage.empty { color: #7a7268; font-size: 14px; }
91
+
92
+ /* --- edit mode ------------------------------------------------------- */
93
+ .editbar { display: none; gap: 8px; align-items: center; flex-wrap: wrap;
94
+ padding: 8px 14px; border-top: 1px solid var(--line); background: #1a1f2b; }
95
+ .editbar.on { display: flex; }
96
+ /* The pre-processing bar sits under the picture rather than in the parameter
97
+ column: it is about the bitmap, and you want it where you can see the
98
+ bitmap change. Hidden until asked for — it is an extra stage, not part of
99
+ tracing. */
100
+ .prebar { flex-wrap: wrap; align-items: flex-start; gap: 10px 18px; }
101
+ .prebar label.row { display: grid; grid-template-columns: auto auto; gap: 2px 8px;
102
+ align-items: center; margin: 0; width: 190px; font-size: 12px; }
103
+ .prebar label.row input[type="range"] { grid-column: 1 / -1; width: 100%; }
104
+ .prebar label.row select { grid-column: 1 / -1; width: 100%; }
105
+ .prebar label.check { font-size: 12px; margin: 0; align-self: center; }
106
+ .prebar .hint { grid-column: 1 / -1; font-size: 11px; }
107
+ .prebar > .hint { width: 100%; }
108
+ .editbar .sep { width: 1px; align-self: stretch; background: var(--line); margin: 0 2px; }
109
+ .editbar .note { color: var(--muted); font-size: 12px; }
110
+ /* The wand's own controls only mean anything while the wand is the tool, and
111
+ the bar is already long enough. !important because the labels carry an
112
+ inline display of their own. */
113
+ .editbar .wand-only { display: none !important; }
114
+ .editbar.wand .wand-only { display: inline-flex !important; }
115
+ .tool { padding: 5px 12px; font-size: 12.5px; }
116
+ .tool.active { background: var(--accent); border-color: var(--accent); color: #10131a; font-weight: 600; }
117
+ /* Hit-testing wants the whole shape, not just its painted middle, and a
118
+ shape scaled down to fit the pane is a few screen pixels wide in places. */
119
+ #outStage.editing svg path { cursor: crosshair; }
120
+ #outStage.editing svg path:hover { stroke: #ff2ec4; stroke-width: 1.5; paint-order: stroke; vector-effect: non-scaling-stroke; }
121
+ #outStage.editing.picking svg path { cursor: copy; }
122
+ #outStage.editing.refining svg path { cursor: crosshair; }
123
+ #outStage.editing.refining svg path:hover { stroke: none; }
124
+ #outStage.editing.smoothing svg path { cursor: grab; }
125
+ #outStage.editing.smoothing svg path:hover { stroke: none; }
126
+ #outStage.editing.wanding svg path { cursor: crosshair; }
127
+ #outStage.editing.wanding svg path.wand-off { opacity: .12; }
128
+ .split {
129
+ display: grid; grid-template-columns: 1fr 1fr; gap: 1px; background: var(--line);
130
+ }
131
+
132
+ @media (max-width: 700px) { .split { grid-template-columns: 1fr; } }
133
+ .pane { background: var(--panel); display: flex; flex-direction: column; min-width: 0; }
134
+ .pane .stage { flex: 1; }
135
+
136
+ #status {
137
+ padding: 10px 14px; border-top: 1px solid var(--line);
138
+ font-family: var(--mono); font-size: 12px; color: var(--muted); white-space: pre-wrap;
139
+ }
140
+ #status.bad { color: var(--bad); }
141
+ #status.good b { color: var(--ok); font-weight: 600; }
142
+
143
+ .palette { display: flex; flex-wrap: wrap; gap: 4px; padding: 10px 14px; border-top: 1px solid var(--line); }
144
+ .sw { width: 22px; height: 22px; border-radius: 4px; border: 1px solid var(--line); }
145
+ .sw[title] { cursor: default; }
146
+ .palette .sw { cursor: copy; }
147
+ .pal-edit { display: flex; flex-wrap: wrap; gap: 6px; align-items: center; margin: 2px 0 8px; }
148
+ .pal-chip { position: relative; width: 34px; height: 30px; }
149
+ .pal-chip input[type="color"] { width: 34px; height: 30px; padding: 0; }
150
+ .pal-chip button {
151
+ position: absolute; top: -7px; right: -7px; width: 17px; height: 17px; line-height: 1;
152
+ padding: 0; border-radius: 999px; font-size: 12px; background: #202634;
153
+ }
154
+ .pal-add { padding: 6px 10px; }
155
+ .pal-empty { color: var(--muted); font-size: 12px; }
156
+ /* Both id-level, but the .hidden rule is more specific, so it wins in mono
157
+ mode. An inline display on the element would beat the class and leave the
158
+ editor on screen in a mode that ignores it. */
159
+ #row-palEdit, #row-algo { display: block; }
160
+ #row-palEdit.hidden, #row-algo.hidden { display: none; }
161
+ #row-algo { margin-bottom: 14px; }
162
+ /* Shown but not in use: the colors are still editable, and touching one is
163
+ what switches the palette over to them. */
164
+ #row-palEdit.pal-inactive .pal-edit, #row-palEdit.pal-inactive .actions { opacity: 0.45; }
165
+ .pal-note { color: var(--muted); font-size: 11.5px; margin: 0 0 8px; }
166
+
167
+ fieldset {
168
+ border: 1px solid var(--line); border-radius: 8px; margin: 0 0 14px; padding: 10px 12px 2px;
169
+ }
170
+ legend { padding: 0 6px; color: var(--muted); font-size: 11px; text-transform: uppercase; letter-spacing: 0.06em; }
171
+ .mono-only.hidden, .color-only.hidden { display: none; }
172
+ </style>
173
+ </head>
174
+ <body>
175
+ <header>
176
+ <p class="crumb"><a href="../">EVG showcase</a> · <a href="../chart-api/">Chart API</a> · tracer</p>
177
+ <h1>Bitmap → SVG tracer</h1>
178
+ <p>
179
+ Lataa JPEG tai PNG, säädä parametreja ja vektoroi kuva selaimessa.
180
+ Moottori on repositorion <code>EvgBitmapTracer</code> (Potrace-tyylinen
181
+ clean-room), käännetty Rangerista ES6:ksi — ei palvelinta, kuva ei lähde minnekään.
182
+ <a href="https://github.com/terotests/Ranger/tree/master/lib/evg">Lähdekoodi</a>
183
+ </p>
184
+ </header>
185
+
186
+ <main>
187
+ <section>
188
+ <div class="head"><span>Parametrit</span><span class="grow"></span></div>
189
+ <div class="body">
190
+ <div class="actions" style="margin-bottom:14px">
191
+ <label class="filebtn">Valitse kuva<input id="file" type="file" accept="image/jpeg,image/png,image/webp,image/gif,.jpg,.jpeg,.png,.webp"></label>
192
+ <button type="button" id="sample">Esimerkki</button>
193
+ </div>
194
+ <p style="margin:0 0 10px;font-size:12px;color:var(--muted)">Voit myös liittää kuvan leikepöydältä (⌘/Ctrl + V).</p>
195
+ <p style="margin:0 0 14px;font-size:12px;color:var(--muted)">Esimerkki: PD-kopio Hokusain <i>Suuresta aallosta</i> (Wikimedia Commons).</p>
196
+
197
+ <div class="row color-only hidden" id="row-algo">
198
+ <div style="font-size:13px;margin-bottom:6px">Algoritmi</div>
199
+ <select id="contourMode" style="width:100%">
200
+ <option value="off" selected>perus — vakiintunut</option>
201
+ <option value="smooth">jatkuvuus — vähemmän valereunoja</option>
202
+ <option value="overlay">päällekkäiset muodot (kokeellinen)</option>
203
+ </select>
204
+ <span class="hint" style="display:block;margin-top:6px">perus on oletus ja se, joka toimii useimmissa tapauksissa. Muut ovat lisiä, eivät korvaajia.</span>
205
+ </div>
206
+ <label class="row color-only hidden" id="row-cEdge">Jatkuvuuskynnys <span class="val" id="v-contourEdge">3</span>
207
+ <input id="contourEdge" type="range" min="1" max="24" step="1" value="3">
208
+ <span class="hint"><b>Jatkuvuus</b>-algoritmin tärkein säädin. Tätä pienempi askel on jatkuva, joten nostaminen kokoaa pinnat yhteen ja jättää jäljelle sen mikä oikeasti on reuna — silmät ja kulmakarvat. Liian ylhäällä kohde sulaa taustaansa.</span>
209
+ </label>
210
+ <fieldset>
211
+ <legend>Värit</legend>
212
+ <label class="row">Värimäärä <span class="val" id="v-colorCount">1</span>
213
+ <input id="colorCount" type="range" min="1" max="64" step="1" value="1">
214
+ <span class="hint">1 = mono (kynnys). &gt;1 = posterize N väriin. Mitattuna muotokuvasta paletti kasvaa aina 64:ään asti (16→15, 32→24, 64→38 tasoa) — sen yläpuolella tulee enää muutama taso lisää ja tiedosto kasvaa nopeammin kuin kuva paranee.</span>
215
+ </label>
216
+ <label class="row mono-only">Kynnys <span class="val" id="v-threshold">128</span>
217
+ <input id="threshold" type="range" min="-1" max="255" step="1" value="128">
218
+ <span class="hint">−1 = Otsu. Muuten 0–255.</span>
219
+ </label>
220
+ <label class="row mono-only">Täyttöväri
221
+ <input id="fillHex" type="color" value="#000000">
222
+ </label>
223
+ <label class="check mono-only"><input id="blackOnWhite" type="checkbox" checked> Tumma on etuala</label>
224
+ <label class="row color-only hidden">Paletti
225
+ <select id="paletteMode">
226
+ <option value="auto" selected>auto — kuvasta</option>
227
+ <option value="seeded">omat + auto</option>
228
+ <option value="fixed">vain omat värit</option>
229
+ </select>
230
+ <span class="hint">auto poimii sävyt kuvasta.</span>
231
+ </label>
232
+ <label class="row color-only hidden" id="row-bias">Painotus
233
+ <select id="paletteBias">
234
+ <option value="area" selected>pinta-ala</option>
235
+ <option value="balanced">tasapaino</option>
236
+ <option value="distinct">erottuvuus</option>
237
+ </select>
238
+ <span class="hint">pinta-ala = suurimmat alueet voittavat, tasapaino = pieni kirkas kohde pärjää, erottuvuus = pelkkä väriero.</span>
239
+ </label>
240
+ <div class="row color-only hidden" style="display:block;border-left:2px solid #2c3a55;padding-left:10px;margin-left:2px">
241
+ <span class="hint" style="display:block;margin:0 0 6px"><b>Paletin tavoite.</b> Kvantisointi kertoo mitä värejä kuvassa on; se ei kerro millä väreillä kuva kannattaa maalata, eikä siihen ole tarkkaa osumaa johon napsauttaa. Nämä säätimet ajetaan vasta kun alueet on jo päätetty, joten ne muuttavat vain sen millä maalataan — eivät sitä missä reunat kulkevat. Jokainen on oletusarvollaan identiteetti.</span>
242
+ <label class="row color-only hidden">Värikylläisyyden paino <span class="val" id="v-paletteChroma">0</span>
243
+ <input id="paletteChroma" type="range" min="0" max="400" step="25" value="0">
244
+ <span class="hint">Kuinka paljon värikkyys painaa pinta-alan rinnalla kun paletti valitaan. Pieni kylläinen kohde suurella harmaalla pohjalla ei muuten saa omaa sävyä lainkaan — se ei tule likimääräisenä vaan katoaa. Toimii yhdessä <b>Paletin painotus</b> = <i>erottuvuus</i> kanssa.</span>
245
+ </label>
246
+ <label class="row color-only hidden">Sävyn murto <span class="val" id="v-paletteMute">100</span>
247
+ <input id="paletteMute" type="range" min="30" max="200" step="5" value="100">
248
+ <span class="hint">Paletin kylläisyys prosentteina. Alle 100 murtaa värit harmaata kohti, yli 100 rikastaa. 100 = kuvan omat värit.</span>
249
+ </label>
250
+ <label class="row color-only hidden">Sävypohja <span class="val" id="v-paletteTint">0</span>
251
+ <input id="paletteTint" type="range" min="0" max="80" step="5" value="0">
252
+ <span class="hint">Alaraja kylläisyydelle: mikään sävy ei tule tätä harmaampana, vaan neutraali saa paletin oman keskisävyn vivahteen. Näin paletin voi murtaa harmaata kohti ilman että siitä tulee kuollutta harmaata.</span>
253
+ </label>
254
+ <label class="row color-only hidden">Lämpö <span class="val" id="v-paletteWarm">0</span>
255
+ <input id="paletteWarm" type="range" min="-60" max="60" step="5" value="0">
256
+ <span class="hint">Siirto keltainen–sininen -akselilla. Positiivinen on lämpimämpi. Siirto eikä sävykierto, koska lämpötilan muutos on juuri sitä.</span>
257
+ </label>
258
+ <label class="row color-only hidden">Paletin kontrasti <span class="val" id="v-paletteContrast">100</span>
259
+ <input id="paletteContrast" type="range" min="50" max="160" step="5" value="100">
260
+ <span class="hint">Kuinka kaukana paletin sävyt ovat toisistaan vaaleudeltaan. Ei kosketa kuvan omaa kontrastia, vain sitä miten paletti levittäytyy.</span>
261
+ </label>
262
+ </div>
263
+ <div class="row color-only hidden" id="row-palEdit">
264
+ <div style="margin-bottom:6px">Omat värit</div>
265
+ <p class="pal-note" id="palNote" hidden>Eivät ole nyt käytössä — Paletti on <b>auto</b>. Poimi tuloksesta tai muokkaa väriä, niin ne otetaan käyttöön.</p>
266
+ <div class="pal-edit" id="palEdit"></div>
267
+ <div class="actions" style="margin:0 0 10px">
268
+ <button type="button" id="palAdd" class="pal-add">+ väri</button>
269
+ <button type="button" id="palPick" class="pal-add">Poimi tuloksesta</button>
270
+ <select id="palPreset" style="max-width:170px">
271
+ <option value="">valmis paletti…</option>
272
+ <option value="#000000,#ffffff">musta + valkoinen</option>
273
+ <option value="#000000,#ffffff,#ffd400">musta, valkoinen, keltainen</option>
274
+ <option value="#111111,#555555,#999999,#dddddd">harmaasävy 4</option>
275
+ <option value="#1d1a17,#6b4f3a,#b08968,#e8d7c3">seepia 4</option>
276
+ <option value="#0b132b,#1c7293,#f4a259,#f4f1de">rannikko 4</option>
277
+ </select>
278
+ </div>
279
+ </div>
280
+ <label class="row color-only hidden">Tausta
281
+ <select id="bgMode">
282
+ <option value="auto" selected>auto — reunoilta</option>
283
+ <option value="color">väri — poista nimetty</option>
284
+ <option value="luma">luma</option>
285
+ <option value="none">ei mitään</option>
286
+ </select>
287
+ <span class="hint">auto poistaa vain reunaan yhtenäisen taustan. Jos se kieltäytyy, valitse väri.</span>
288
+ </label>
289
+ <label class="row color-only hidden" id="row-bgColor">Taustaväri
290
+ <input id="bgColor" type="color" value="#ffffff">
291
+ <span class="hint">Tulvitetaan reunoilta; sama väri kuvion sisällä säilyy.</span>
292
+ </label>
293
+ <label class="row color-only hidden" id="row-bgTol">Taustan toleranssi <span class="val" id="v-bgTolerance">24</span>
294
+ <input id="bgTolerance" type="range" min="0" max="90" step="1" value="24">
295
+ <span class="hint">Nosta jos taustassa on liukua tai pakkausroskaa.</span>
296
+ </label>
297
+ <label class="row color-only hidden">skipLuma <span class="val" id="v-skipLuma">250</span>
298
+ <input id="skipLuma" type="range" min="0" max="256" step="1" value="250">
299
+ <span class="hint">Vain luma-tilassa: jätä pois pikselit joiden luma ≥ raja.</span>
300
+ </label>
301
+ <label class="row color-only hidden">Sävyjen yhdistäminen <span class="val" id="v-minColorDelta">10</span>
302
+ <input id="minColorDelta" type="range" min="0" max="40" step="1" value="10">
303
+ <span class="hint">Tätä lähempänä olevat sävyt yhdistetään.</span>
304
+ </label>
305
+ <label class="row color-only hidden" id="row-detailShare">Yksityiskohtien karkeus <span class="val" id="v-detailMinShare">8</span>
306
+ <input id="detailMinShare" type="range" min="0" max="30" step="1" value="8">
307
+ <span class="hint">Silmien kaltaiset kohdat kvantisoidaan omalla paletillaan — sillä kokonaisesta naamasta silmälle jää pari sävyä, yksinään zoomattuna koko budjetti. Tämä on se säädin joka erottaa rakenteen rakeesta: <b>0</b> = kaikki yksityiskohdat ja kaikki rae, <b>8</b> = luomi, ripsiraja ja silmän aukko näkyvät ilman raetta, <b>15+</b> = alkaa syödä rakennettakin.</span>
308
+ </label>
309
+ <label class="row color-only hidden" id="row-detailMerge">Yksityiskohtien värien yhdistäminen <span class="val" id="v-detailColorMerge">16</span>
310
+ <input id="detailColorMerge" type="range" min="0" max="48" step="1" value="16">
311
+ <span class="hint">Yksityiskohta-alueet jakavat yhden paletin, ja tämä on se raja jota lähempänä olevat sävyt ovat sama merkintä siinä. Ilman tätä jokainen alue keksii omat värinsä toisistaan tietämättä. Muotokuvassa <b>0</b> = 456 väriä, <b>16</b> = 97, <b>24</b> = 55 — virhe kasvaa 0,5 %. <b>48</b> yhdistää jo liikaa ja kulmakarvat sinertävät.</span>
312
+ </label>
313
+ <label class="row color-only hidden" id="row-detailMax">Yksityiskohtien värikatto <span class="val" id="v-detailColorMax">12</span>
314
+ <input id="detailColorMax" type="range" min="0" max="64" step="2" value="12">
315
+ <span class="hint">Enintään näin monta väriä yksityiskohta-alueet saavat lisätä yhteensä, eli värejä on korkeintaan <b>värimäärä + tämä</b>. Ilman kattoa jokainen alue tuo omansa: muotokuva jolta pyydettiin 6 sävyä palautti 103. Katolla 12 se on 18, virhettä 3,6 % enemmän. <b>0</b> = ei kattoa.</span>
316
+ </label>
317
+ <label class="check color-only hidden" id="row-detailTrue"><input id="detailTrueColor" type="checkbox"> Tarkat värit yksityiskohdissa
318
+ <span class="hint">Silmien ja hampaiden kaltaisissa kohdissa — missä selvästi eri värit kohtaavat muutaman pikselin sisällä — muoto saa pitää oman mitatun värinsä sen sijaan että se pyöristettäisiin palettiin. Vain näin tila voi lisätä tarkkuutta sen sijaan että piirtäisi uudelleen sen mikä jo on. Muotokuvassa suun virhe pieneni 8,7 %, mutta värejä tuli 10 sijaan 109 — eli värimäärä lakkaa olemasta katto.</span>
319
+ </label>
320
+ <label class="row color-only hidden" id="row-oSim">Samankaltaisuuden raja <span class="val" id="v-overlaySimilar">8</span>
321
+ <input id="overlaySimilar" type="range" min="1" max="40" step="1" value="8">
322
+ <span class="hint">Muoto pysähtyy myös kun se on näin samanlainen kuin alla oleva. Käytä gradienttien kanssa.</span>
323
+ </label>
324
+ <label class="row color-only hidden" id="row-eRun">Reunan vähimmäispituus <span class="val" id="v-edgeMinRun">3</span>
325
+ <input id="edgeMinRun" type="range" min="0" max="12" step="1" value="3">
326
+ <span class="hint">Reuna vaatii <b>ainutlaatuisuuden</b> (voimakkain suunnassaan) ja <b>jatkuvuuden</b> (näin pitkä juova). 0 = pois.</span>
327
+ </label>
328
+ <label class="row color-only hidden" id="row-cSpread">Alueen hajonta <span class="val" id="v-contourSpread">48</span>
329
+ <input id="contourSpread" type="range" min="8" max="160" step="4" value="48">
330
+ <span class="hint">Kuinka kauas alue saa ajautua keskiarvostaan — talutushihna liukuvärille.</span>
331
+ </label>
332
+ <label class="check color-only hidden"><input id="gradientFill" type="checkbox"> Gradienttitäytöt (kokeellinen)</label>
333
+ <label class="row color-only hidden" id="row-gGain">Gradientin kynnys <span class="val" id="v-gradientGain">15</span>
334
+ <input id="gradientGain" type="range" min="0" max="60" step="1" value="15">
335
+ <span class="hint">Liuku korvaa tasaisen värin vain jos se selittää näin monta prosenttia enemmän. Toimii hyvin kuvissa joissa on isoja pehmeitä pintoja; yksityiskohtainen valokuva pilkkoutuu sadoiksi alueiksi ja tulos on huono. Vain SVG-vienti.</span>
336
+ </label>
337
+ <label class="row color-only hidden">Tasot
338
+ <select id="layerMode">
339
+ <option value="stacked" selected>päällekkäin — ei saumoja</option>
340
+ <option value="flat">vierekkäin — pienempi SVG</option>
341
+ </select>
342
+ <span class="hint">Päällekkäin: ei valkoisia saumoja. Vierekkäin: pienempi tiedosto, saumat näkyvät.</span>
343
+ </label>
344
+ <label class="row color-only hidden">Pehmennys <span class="val" id="v-smooth">0</span>
345
+ <input id="smooth" type="range" min="0" max="4" step="1" value="0">
346
+ <span class="hint">Mediaani ennen värien poimintaa. 0 = pois (piirrokset), 2 = valokuva.</span>
347
+ </label>
348
+ <label class="row color-only hidden">Pienin alue <span class="val" id="v-minRegion">6</span>
349
+ <input id="minRegion" type="range" min="0" max="400" step="2" value="6">
350
+ <span class="hint">Tätä pienemmät läiskät sulautuvat ympäristöönsä. Valokuvalle 40–150. <b>Päällekkäiset muodot</b> -algoritmissa tämä on se säädin, joka pitää tuloksen kohtuullisen kokoisena: valokuvassa 6 → 24 pudotti muotojen määrän noin neljäsosaan.</span>
351
+ </label>
352
+ <label class="row color-only hidden">Erottuvuusraja <span class="val" id="v-absorbContrast">40</span>
353
+ <input id="absorbContrast" type="range" min="0" max="120" step="4" value="40">
354
+ <span class="hint">Pienin alue nielaisee myös sen, mikä <i>erottuu</i> ympäristöstään, ellei tämä estä sitä: tätä kauempana olevan sävyn saanut läiskä säilyy koostaan riippumatta. Aallon vaahto ja veneet ovat tällaisia. 0 = vanha ehdoton nielaisu.</span>
355
+ </label>
356
+ <div class="row color-only hidden" style="display:block">
357
+ <div class="actions" style="margin:0 0 4px">
358
+ <button type="button" id="presetArt">Piirros</button>
359
+ <button type="button" id="presetPhoto">Valokuva</button>
360
+ <button type="button" id="presetBroken">Murretut sävyt</button>
361
+ </div>
362
+ <span class="hint" style="display:block;margin-top:6px">Valmiit asetukset. Kukin nollaa myös ne säätimet joita se ei itse aseta, joten presetit eivät kasaudu päällekkäin. <b>Murretut sävyt</b> valitsee paletin sen värien eikä uskollisuuden perusteella.</span>
363
+ </div>
364
+ </fieldset>
365
+
366
+ <fieldset>
367
+ <legend>Jälki</legend>
368
+ <label class="row">turdsize <span class="val" id="v-turdsize">2</span>
369
+ <input id="turdsize" type="range" min="0" max="40" step="1" value="2">
370
+ <span class="hint">Pienet täplät pois (|area| ≤ raja).</span>
371
+ </label>
372
+ <label class="row">alphamax <span class="val" id="v-alphamax">1.00</span>
373
+ <input id="alphamax" type="range" min="0" max="1.34" step="0.01" value="1">
374
+ <span class="hint">Korkeampi → pehmeämpi, vähemmän kulmia.</span>
375
+ </label>
376
+ <label class="row">opttolerance <span class="val" id="v-opttolerance">0.20</span>
377
+ <input id="opttolerance" type="range" min="0" max="2" step="0.05" value="0.2">
378
+ </label>
379
+ <label class="check"><input id="optcurve" type="checkbox" checked> optcurve (kuutiot)</label>
380
+ <label class="row">turnpolicy
381
+ <select id="turnpolicy">
382
+ <option value="minority" selected>minority</option>
383
+ <option value="majority">majority</option>
384
+ <option value="black">black</option>
385
+ <option value="white">white</option>
386
+ <option value="left">left</option>
387
+ <option value="right">right</option>
388
+ </select>
389
+ </label>
390
+ </fieldset>
391
+
392
+ <fieldset>
393
+ <legend>Näkymä</legend>
394
+ <label class="check"><input id="checker" type="checkbox" checked> Shakkiruudukko — näyttää läpinäkyvän</label>
395
+ </fieldset>
396
+
397
+ <fieldset>
398
+ <legend>Esikäsittely</legend>
399
+ <label class="row">Max leveys <span class="val" id="v-maxW">640</span>
400
+ <input id="maxW" type="range" min="160" max="1200" step="10" value="640">
401
+ <span class="hint">Suuret kuvat skaalataan ennen jäljitystä.</span>
402
+ </label>
403
+ </fieldset>
404
+
405
+ <div class="actions">
406
+ <button type="button" class="primary" id="run" disabled>Vektoroi</button>
407
+ <button type="button" id="dl" disabled>Lataa SVG</button>
408
+ </div>
409
+ </div>
410
+ </section>
411
+
412
+ <div>
413
+ <section>
414
+ <div class="split">
415
+ <div class="pane">
416
+ <div class="head">Lähde<span class="grow"></span>
417
+ <button type="button" class="tool" id="preToggle">Esikäsittely</button>
418
+ </div>
419
+ <div class="stage empty" id="srcStage">Ei kuvaa</div>
420
+ </div>
421
+ <div class="pane">
422
+ <div class="head">SVG<span class="grow"></span>
423
+ <button type="button" class="tool" id="editToggle" disabled>Muokkaa</button>
424
+ </div>
425
+ <div class="stage empty" id="outStage">—</div>
426
+ </div>
427
+ </div>
428
+ <div class="editbar" id="editbar">
429
+ <button type="button" class="tool active" id="toolRefine">Tarkennin</button>
430
+ <button type="button" class="tool" id="toolMerge">Yhdistä</button>
431
+ <button type="button" class="tool" id="toolSmooth">Pehmennä</button>
432
+ <button type="button" class="tool" id="toolWand">Taikasauva</button>
433
+ <span class="sep"></span>
434
+ <input type="color" id="editColor" value="#c9a227" title="Yhdistämisen kohdeväri">
435
+ <button type="button" class="tool" id="toolPick">Poimi väri</button>
436
+ <span class="sep"></span>
437
+ <span class="note" id="toolHelp"></span>
438
+ <label class="note wand-only" style="display:flex;align-items:center;gap:6px" title="Kuinka kaukana napsautetusta sävystä valinta saa vielä kasvaa. 0 ottaa vain saman värin, iso luku vie yli sävyrajojen.">Sieto
439
+ <input id="wandTol" type="range" min="0" max="180" step="4" value="40" style="width:80px">
440
+ <span class="val" id="v-wandTol">40</span>
441
+ </label>
442
+ <label class="note wand-only" style="display:flex;align-items:center;gap:6px" title="Rajaa kuvan reunat valitun kohteen ympärille, jolloin tuloksesta tulee ikonin kokoinen."><input type="checkbox" id="wandCrop" checked> rajaa reunoihin</label>
443
+ <button type="button" class="tool wand-only" id="wandCut">Eristä</button>
444
+ <label class="note" style="display:flex;align-items:center;gap:6px" title="Kuinka monta omaa väriä tarkennettu paikka saa keksiä reunaltaan kiinnitettyjen lisäksi. Pieni luku pitää sauman näkymättömänä.">Lisävärit
445
+ <input id="refineExtra" type="range" min="0" max="16" step="1" value="4" style="width:70px">
446
+ <span class="val" id="v-refineExtra">4</span>
447
+ </label>
448
+ <label class="note" style="display:flex;align-items:center;gap:6px" title="Kuinka voimakkaasti reunan muodot palautetaan ympäröiviin sävyihin">Sulautus
449
+ <input id="edgeBlend" type="range" min="0" max="120" step="4" value="90" style="width:70px">
450
+ <span class="val" id="v-edgeBlend">90</span>
451
+ </label>
452
+ <label class="note" style="display:flex;align-items:center;gap:6px">Koko
453
+ <input id="refineSize" type="range" min="12" max="120" step="4" value="40" style="width:90px">
454
+ <span class="val" id="v-refineSize">40</span>px
455
+ </label>
456
+ <span class="sep"></span>
457
+ <button type="button" class="tool" id="editUndo">Kumoa</button>
458
+ <button type="button" class="tool" id="editReset">Palauta</button>
459
+ <span class="note" id="editNote">Napsauta muotoa.</span>
460
+ </div>
461
+ <div class="editbar prebar" id="prebar">
462
+ <div style="display:flex;gap:8px;align-items:center;width:100%">
463
+ <button type="button" class="tool" id="preUndo" disabled>Kumoa</button>
464
+ <button type="button" class="tool" id="preReset">Palauta alkuperäinen</button>
465
+ <span class="note" id="preNote">Kuva on tämä. Säätimet muokkaavat sitä.</span>
466
+ </div>
467
+ <label class="row">Valmis ketju
468
+ <select id="prePreset" style="width:100%">
469
+ <option value="none" selected>ei mitään</option>
470
+ <option value="logo">logo — kontrasti + puhdistus</option>
471
+ <option value="lineart">viivapiirros — harmaa + terävöitys</option>
472
+ <option value="photo">valokuva — kohinanpoisto</option>
473
+ <option value="poster">juliste — posterisointi</option>
474
+ </select>
475
+ <span class="hint">Asettaa alla olevat säätimet. Ne jäävät muokattaviksi.</span>
476
+ </label>
477
+ <label class="check"><input id="preGray" type="checkbox"> Harmaasävy</label>
478
+ <label class="check"><input id="preInvert" type="checkbox"> Käännä värit</label>
479
+ <label class="row">Kirkkaus <span class="val" id="v-preBright">0</span>
480
+ <input id="preBright" type="range" min="-60" max="60" step="2" value="0">
481
+ </label>
482
+ <label class="row">Kontrasti <span class="val" id="v-preContrast">0</span>
483
+ <input id="preContrast" type="range" min="-60" max="120" step="2" value="0">
484
+ </label>
485
+ <label class="row">Gamma <span class="val" id="v-preGamma">100</span>
486
+ <input id="preGamma" type="range" min="30" max="250" step="5" value="100">
487
+ <span class="hint">Väli- ja varjoalueet ilman että ääripäät liikkuvat.</span>
488
+ </label>
489
+ <label class="row">Kylläisyys <span class="val" id="v-preSat">100</span>
490
+ <input id="preSat" type="range" min="0" max="250" step="5" value="100">
491
+ </label>
492
+ <label class="check"><input id="preMedian" type="checkbox"> Poista pilkut (mediaani)
493
+ <span class="hint">Vie yksittäisen kohinapikselin mutta jättää reunan — juuri se mitä sumennus ei osaa.</span>
494
+ </label>
495
+ <label class="row">Sumennus <span class="val" id="v-preBlur">0</span>
496
+ <input id="preBlur" type="range" min="0" max="8" step="1" value="0">
497
+ <span class="hint">Tämä säädin ratkaisee eniten: se päättää kuinka paljon yksityiskohtaa ylipäänsä pääsee vektoroitavaksi.</span>
498
+ </label>
499
+ <label class="row">Terävöitys <span class="val" id="v-preSharpen">0</span>
500
+ <input id="preSharpen" type="range" min="0" max="150" step="5" value="0">
501
+ </label>
502
+ <label class="row">Posterisointi <span class="val" id="v-prePost">0</span>
503
+ <input id="prePost" type="range" min="0" max="16" step="1" value="0">
504
+ <span class="hint">0 = pois. Sävytasoja per kanava <b>ennen</b> kvantisointia — eri asia kuin Värimäärä, joka valitsee paletin.</span>
505
+ </label>
506
+ <p class="hint" style="margin:8px 0 0">Kynnys, värimäärä, kohinanpoisto, reunan voimakkuus ja pienin yksityiskohta ovat jo omina säätiminään alempana — ne tekevät työnsä jäljityksen sisällä, missä ne ovat tarkempia kuin bittikartalle tehtynä.</p>
507
+ </div>
508
+ <div class="palette" id="palette" hidden></div>
509
+ <div id="status">Ladataan tracer-bundlea…</div>
510
+ </section>
511
+ </div>
512
+ </main>
513
+
514
+ <script src="evg_bitmap_tracer.js"></script>
515
+ <script>
516
+ (function () {
517
+ var $ = function (id) { return document.getElementById(id); };
518
+ var file = $("file"), sample = $("sample"), runBtn = $("run"), dlBtn = $("dl");
519
+ var srcStage = $("srcStage"), outStage = $("outStage"), statusEl = $("status"), paletteEl = $("palette");
520
+ var colorCount = $("colorCount"), threshold = $("threshold"), skipLuma = $("skipLuma");
521
+ var bgMode = $("bgMode"), minColorDelta = $("minColorDelta"), minRegion = $("minRegion");
522
+ var absorbContrast = $("absorbContrast");
523
+ var paletteChroma = $("paletteChroma"), paletteMute = $("paletteMute"), paletteTint = $("paletteTint");
524
+ var paletteWarm = $("paletteWarm"), paletteContrast = $("paletteContrast");
525
+ var paletteMode = $("paletteMode"), paletteBias = $("paletteBias"), smooth = $("smooth"), layerMode = $("layerMode");
526
+ var bgColor = $("bgColor"), bgTolerance = $("bgTolerance"), checker = $("checker");
527
+ var contourMode = $("contourMode"), contourEdge = $("contourEdge"), contourSpread = $("contourSpread");
528
+ var gradientFill = $("gradientFill"), gradientGain = $("gradientGain");
529
+ var overlaySimilar = $("overlaySimilar"), edgeMinRun = $("edgeMinRun");
530
+ var detailTrueColor = $("detailTrueColor"), detailMinShare = $("detailMinShare");
531
+ var detailColorMerge = $("detailColorMerge"), detailColorMax = $("detailColorMax");
532
+ var palEdit = $("palEdit"), rowBias = $("row-bias"), rowPalEdit = $("row-palEdit");
533
+ var userPalette = ["#000000", "#ffffff"];
534
+ var turdsize = $("turdsize"), alphamax = $("alphamax"), opttolerance = $("opttolerance");
535
+ var optcurve = $("optcurve"), turnpolicy = $("turnpolicy"), blackOnWhite = $("blackOnWhite");
536
+ var fillHex = $("fillHex"), maxW = $("maxW");
537
+ var monoOnly = document.querySelectorAll(".mono-only");
538
+ var colorOnly = document.querySelectorAll(".color-only");
539
+
540
+ var sourceBitmap = null; // { w, h, rgba: Uint8ClampedArray }
541
+ var processedBitmap = null; // the same pixels after the pre-processing chain
542
+
543
+ // ------------------------------------------------------- esikäsittely
544
+ //
545
+ // Filters that run on the bitmap before anything is quantized or traced. The
546
+ // order is fixed and it matters: exposure first, because everything after it
547
+ // reads brightness; then color; then blur, which is the one that decides how
548
+ // much detail survives at all; then sharpening, which only makes sense on
549
+ // what the blur left; and posterize last, because it is a decision about the
550
+ // final levels rather than a correction.
551
+ //
552
+ // Deliberately not a long list. Most of what a filter menu usually offers is
553
+ // already here as a tracing parameter — the color count IS quantization, the
554
+ // smallest area IS despeckle, mono mode IS threshold, the background section
555
+ // IS background removal — and adding a second way to do the same thing makes
556
+ // a page harder to reason about, not more capable. What is here is the work
557
+ // that has to happen *before* the palette is chosen, because afterwards it is
558
+ // too late.
559
+ function lut(fn) {
560
+ var t = new Uint8Array(256);
561
+ for (var i = 0; i < 256; i++) {
562
+ var v = fn(i / 255) * 255;
563
+ t[i] = v < 0 ? 0 : v > 255 ? 255 : v;
564
+ }
565
+ return t;
566
+ }
567
+
568
+ // Separable box blur run three times ≈ a Gaussian, and it is O(n) per pass
569
+ // rather than O(n·r²), which matters at a radius a person would actually pick.
570
+ function boxBlur(src, w, h, r) {
571
+ if (r < 1) return src;
572
+ var tmp = new Uint8ClampedArray(src.length);
573
+ var out = new Uint8ClampedArray(src);
574
+ for (var pass = 0; pass < 3; pass++) {
575
+ blurAxis(out, tmp, w, h, r, true);
576
+ blurAxis(tmp, out, w, h, r, false);
577
+ }
578
+ return out;
579
+ }
580
+
581
+ function blurAxis(src, dst, w, h, r, horiz) {
582
+ var outer = horiz ? h : w, inner = horiz ? w : h;
583
+ var step = horiz ? 4 : w * 4;
584
+ var jump = horiz ? w * 4 : 4;
585
+ var win = r * 2 + 1;
586
+ for (var o = 0; o < outer; o++) {
587
+ var base = o * jump;
588
+ for (var c = 0; c < 4; c++) {
589
+ var sum = 0;
590
+ for (var k = -r; k <= r; k++) {
591
+ var q = k < 0 ? 0 : k >= inner ? inner - 1 : k;
592
+ sum += src[base + q * step + c];
593
+ }
594
+ for (var i = 0; i < inner; i++) {
595
+ dst[base + i * step + c] = sum / win;
596
+ var addI = i + r + 1, subI = i - r;
597
+ var a = addI >= inner ? inner - 1 : addI;
598
+ var b = subI < 0 ? 0 : subI;
599
+ sum += src[base + a * step + c] - src[base + b * step + c];
600
+ }
601
+ }
602
+ }
603
+ }
604
+
605
+ // Median of the 3x3 neighbourhood, per channel. This is the one that removes
606
+ // a stray pixel without softening the edge it sits next to, which is exactly
607
+ // what a blur cannot do and exactly what JPEG speckle needs.
608
+ function median3(src, w, h) {
609
+ var out = new Uint8ClampedArray(src);
610
+ var v = new Array(9);
611
+ for (var y = 1; y < h - 1; y++) {
612
+ for (var x = 1; x < w - 1; x++) {
613
+ var o = (y * w + x) * 4;
614
+ for (var c = 0; c < 3; c++) {
615
+ var n = 0;
616
+ for (var dy = -1; dy <= 1; dy++) {
617
+ for (var dx = -1; dx <= 1; dx++) v[n++] = src[((y + dy) * w + (x + dx)) * 4 + c];
618
+ }
619
+ v.sort(function (a, b) { return a - b; });
620
+ out[o + c] = v[4];
621
+ }
622
+ }
623
+ }
624
+ return out;
625
+ }
626
+
627
+ function preprocess(src, w, h, o) {
628
+ var out = new Uint8ClampedArray(src);
629
+ var i, n = w * h;
630
+
631
+ // --- exposure: brightness, contrast, gamma, as one lookup ---
632
+ if (o.brightness !== 0 || o.contrast !== 0 || o.gamma !== 100) {
633
+ var g = Math.max(0.05, o.gamma / 100);
634
+ var cc = o.contrast / 100;
635
+ var t = lut(function (u) {
636
+ u = u + o.brightness / 100;
637
+ u = (u - 0.5) * (1 + cc) + 0.5;
638
+ return Math.pow(u < 0 ? 0 : u > 1 ? 1 : u, 1 / g);
639
+ });
640
+ for (i = 0; i < n; i++) {
641
+ var q = i * 4;
642
+ out[q] = t[out[q]]; out[q + 1] = t[out[q + 1]]; out[q + 2] = t[out[q + 2]];
643
+ }
644
+ }
645
+
646
+ // --- color: saturation, then grayscale as its limit ---
647
+ if (o.grayscale || o.saturation !== 100) {
648
+ var sat = o.grayscale ? 0 : o.saturation / 100;
649
+ for (i = 0; i < n; i++) {
650
+ var p = i * 4;
651
+ var lum = 0.2126 * out[p] + 0.7152 * out[p + 1] + 0.0722 * out[p + 2];
652
+ out[p] = lum + (out[p] - lum) * sat;
653
+ out[p + 1] = lum + (out[p + 1] - lum) * sat;
654
+ out[p + 2] = lum + (out[p + 2] - lum) * sat;
655
+ }
656
+ }
657
+
658
+ // --- detail: median first (specks), then blur (everything small) ---
659
+ if (o.median) out = median3(out, w, h);
660
+ if (o.blur > 0) out = boxBlur(out, w, h, o.blur);
661
+
662
+ // --- sharpen: unsharp mask against a blur of what is left ---
663
+ if (o.sharpen > 0) {
664
+ var soft = boxBlur(out, w, h, 1);
665
+ var amt = o.sharpen / 100;
666
+ for (i = 0; i < n * 4; i++) {
667
+ if ((i & 3) === 3) continue;
668
+ out[i] = out[i] + (out[i] - soft[i]) * amt;
669
+ }
670
+ }
671
+
672
+ // --- posterize: a decision about levels, so it goes last ---
673
+ if (o.posterize > 1) {
674
+ var steps = o.posterize - 1;
675
+ var pt = lut(function (u) { return Math.round(u * steps) / steps; });
676
+ for (i = 0; i < n; i++) {
677
+ var z = i * 4;
678
+ out[z] = pt[out[z]]; out[z + 1] = pt[out[z + 1]]; out[z + 2] = pt[out[z + 2]];
679
+ }
680
+ }
681
+
682
+ if (o.invert) {
683
+ for (i = 0; i < n; i++) {
684
+ var w4 = i * 4;
685
+ out[w4] = 255 - out[w4]; out[w4 + 1] = 255 - out[w4 + 1]; out[w4 + 2] = 255 - out[w4 + 2];
686
+ }
687
+ }
688
+ return out;
689
+ }
690
+
691
+ var lastSvg = "";
692
+ var lastPalette = [];
693
+ var debounceTimer = null;
694
+ var busy = false;
695
+ var pending = false;
696
+
697
+ function setStatus(msg, kind) {
698
+ statusEl.textContent = msg;
699
+ statusEl.className = kind || "";
700
+ statusHead = "";
701
+ }
702
+
703
+ // The half of the status line that describes the drawing rather than the
704
+ // trace. An edit changes the shapes and the bytes, and the line said what
705
+ // they were when the tracer finished — so after an hour of editing it was
706
+ // quoting a file that no longer existed.
707
+ var statusHead = "";
708
+ function refreshSize() {
709
+ if (!statusHead) return;
710
+ var live = outStage.querySelector("svg");
711
+ if (!live) return;
712
+ var text = new XMLSerializer().serializeToString(live);
713
+ var shapes = live.querySelectorAll("path").length;
714
+ statusEl.innerHTML = statusHead.replace(/layers=\d+/, "layers=" + shapes)
715
+ + " · svg=" + text.length + " B";
716
+ }
717
+
718
+ function bindRange(input, label, fmt) {
719
+ function sync() {
720
+ label.textContent = fmt ? fmt(+input.value) : String(input.value);
721
+ }
722
+ input.addEventListener("input", function () {
723
+ sync();
724
+ schedule();
725
+ });
726
+ sync();
727
+ }
728
+
729
+ bindRange(colorCount, $("v-colorCount"));
730
+ bindRange(threshold, $("v-threshold"), function (v) { return v < 0 ? "Otsu" : String(v); });
731
+ bindRange(skipLuma, $("v-skipLuma"));
732
+ bindRange(minColorDelta, $("v-minColorDelta"));
733
+ bindRange(minRegion, $("v-minRegion"));
734
+ bindRange(absorbContrast, $("v-absorbContrast"));
735
+ bindRange(paletteChroma, $("v-paletteChroma"));
736
+ bindRange(paletteMute, $("v-paletteMute"));
737
+ bindRange(paletteTint, $("v-paletteTint"));
738
+ bindRange(paletteWarm, $("v-paletteWarm"));
739
+ bindRange(paletteContrast, $("v-paletteContrast"));
740
+ bindRange(smooth, $("v-smooth"));
741
+ bindRange(bgTolerance, $("v-bgTolerance"));
742
+ bindRange(contourEdge, $("v-contourEdge"));
743
+ bindRange(contourSpread, $("v-contourSpread"));
744
+ bindRange(gradientGain, $("v-gradientGain"));
745
+ bindRange(overlaySimilar, $("v-overlaySimilar"));
746
+ bindRange(detailMinShare, $("v-detailMinShare"));
747
+ bindRange(detailColorMerge, $("v-detailColorMerge"));
748
+ bindRange(detailColorMax, $("v-detailColorMax"));
749
+ bindRange(edgeMinRun, $("v-edgeMinRun"));
750
+
751
+ function syncChecker() {
752
+ [srcStage, outStage].forEach(function (el) {
753
+ el.classList.toggle("checker", checker.checked);
754
+ });
755
+ }
756
+ checker.addEventListener("change", syncChecker);
757
+ syncChecker();
758
+
759
+ // Photographic noise needs both knobs at once, so offer them as a pair.
760
+ // A preset is a set of controls, not two of them: leaving the palette dials
761
+ // where the last preset put them is how a "Piirros" comes back graded.
762
+ function applyPreset(vals) {
763
+ var defaults = {
764
+ smooth: 0, minRegion: 6, absorbContrast: 40,
765
+ paletteChroma: 0, paletteMute: 100, paletteTint: 0,
766
+ paletteWarm: 0, paletteContrast: 100, paletteBias: "area"
767
+ };
768
+ var touched = [];
769
+ Object.keys(defaults).forEach(function (id) {
770
+ var el = $(id);
771
+ if (!el) return;
772
+ var v = vals.hasOwnProperty(id) ? vals[id] : defaults[id];
773
+ el.value = String(v);
774
+ touched.push(el);
775
+ });
776
+ touched.forEach(function (el) {
777
+ el.dispatchEvent(new Event("input", { bubbles: true }));
778
+ el.dispatchEvent(new Event("change", { bubbles: true }));
779
+ });
780
+ }
781
+ $("presetArt").addEventListener("click", function () { applyPreset({ smooth: 0, minRegion: 6 }); });
782
+ $("presetPhoto").addEventListener("click", function () { applyPreset({ smooth: 2, minRegion: 60, absorbContrast: 56 }); });
783
+ // The palette chosen for its colors rather than its fidelity — the setting
784
+ // that puts the ochre of the boat hulls into the Hokusai example at all.
785
+ $("presetBroken").addEventListener("click", function () {
786
+ applyPreset({ smooth: 0, minRegion: 4, absorbContrast: 24, paletteBias: "distinct",
787
+ paletteChroma: 300, paletteMute: 130, paletteTint: 30 });
788
+ });
789
+ bindRange(turdsize, $("v-turdsize"));
790
+ bindRange(alphamax, $("v-alphamax"), function (v) { return v.toFixed(2); });
791
+ bindRange(opttolerance, $("v-opttolerance"), function (v) { return v.toFixed(2); });
792
+ bindRange(maxW, $("v-maxW"));
793
+
794
+ // Editing a color while the palette is on "auto" is the user asking for that
795
+ // color, not a no-op: switch the mode over rather than silently ignoring it.
796
+ function activatePalette() {
797
+ if (paletteMode.value === "auto") {
798
+ paletteMode.value = "fixed";
799
+ syncMode();
800
+ }
801
+ }
802
+
803
+ // The editable list of colors for the "omat" / "omat + auto" palette modes.
804
+ function renderPalEdit() {
805
+ palEdit.innerHTML = "";
806
+ if (!userPalette.length) {
807
+ var empty = document.createElement("span");
808
+ empty.className = "pal-empty";
809
+ empty.textContent = "ei värejä — paletti poimitaan kuvasta";
810
+ palEdit.appendChild(empty);
811
+ return;
812
+ }
813
+ userPalette.forEach(function (hex, i) {
814
+ var chip = document.createElement("span");
815
+ chip.className = "pal-chip";
816
+ var inp = document.createElement("input");
817
+ inp.type = "color";
818
+ inp.value = hex;
819
+ inp.title = hex;
820
+ inp.addEventListener("input", function () {
821
+ userPalette[i] = inp.value;
822
+ inp.title = inp.value;
823
+ activatePalette();
824
+ schedule();
825
+ });
826
+ var del = document.createElement("button");
827
+ del.type = "button";
828
+ del.textContent = "×";
829
+ del.title = "Poista";
830
+ del.addEventListener("click", function () {
831
+ userPalette.splice(i, 1);
832
+ renderPalEdit();
833
+ schedule();
834
+ });
835
+ chip.appendChild(inp);
836
+ chip.appendChild(del);
837
+ palEdit.appendChild(chip);
838
+ });
839
+ }
840
+
841
+ $("palAdd").addEventListener("click", function () {
842
+ userPalette.push("#808080");
843
+ renderPalEdit();
844
+ activatePalette();
845
+ schedule();
846
+ });
847
+ $("palPreset").addEventListener("change", function (e) {
848
+ if (!e.target.value) return;
849
+ userPalette = e.target.value.split(",");
850
+ e.target.value = "";
851
+ renderPalEdit();
852
+ activatePalette();
853
+ schedule();
854
+ });
855
+ // Take the palette the quantizer just produced and make it editable.
856
+ function adoptPalette(hexes) {
857
+ if (!hexes || !hexes.length) return;
858
+ userPalette = hexes.slice();
859
+ renderPalEdit();
860
+ activatePalette();
861
+ schedule();
862
+ }
863
+ $("palPick").addEventListener("click", function () { adoptPalette(lastPalette); });
864
+
865
+ function syncMode() {
866
+ var color = +colorCount.value > 1;
867
+ monoOnly.forEach(function (el) { el.classList.toggle("hidden", color); });
868
+ colorOnly.forEach(function (el) { el.classList.toggle("hidden", !color); });
869
+ // skipLuma only does anything in luma mode.
870
+ skipLuma.closest("label").classList.toggle("hidden", !color || bgMode.value !== "luma");
871
+ $("row-bgColor").classList.toggle("hidden", !color || bgMode.value !== "color");
872
+ var floods = bgMode.value === "auto" || bgMode.value === "color";
873
+ $("row-bgTol").classList.toggle("hidden", !color || !floods);
874
+ var cont = contourMode.value !== "off";
875
+ $("row-oSim").classList.toggle("hidden", !color || contourMode.value !== "overlay");
876
+ $("row-detailTrue").classList.toggle("hidden", !color || contourMode.value !== "overlay");
877
+ $("row-detailShare").classList.toggle("hidden", !color || contourMode.value !== "overlay");
878
+ $("row-detailMerge").classList.toggle("hidden", !color || contourMode.value !== "overlay");
879
+ $("row-detailMax").classList.toggle("hidden", !color || contourMode.value !== "overlay");
880
+ $("row-cEdge").classList.toggle("hidden", !color || !cont);
881
+ $("row-cSpread").classList.toggle("hidden", !color || !cont);
882
+ $("row-eRun").classList.toggle("hidden", !color || !cont);
883
+ gradientFill.closest("label").classList.toggle("hidden", !color);
884
+ $("row-gGain").classList.toggle("hidden", !color || !gradientFill.checked);
885
+ var mode = paletteMode.value;
886
+ rowBias.classList.toggle("hidden", !color || mode === "fixed");
887
+ // Kept on screen even on "auto" — it is how you reach "Poimi tuloksesta",
888
+ // and hiding it is what made the controls look inert.
889
+ rowPalEdit.classList.toggle("hidden", !color);
890
+ rowPalEdit.classList.toggle("pal-inactive", mode === "auto");
891
+ $("palNote").hidden = mode !== "auto";
892
+ // "omat" replaces the count; "omat + auto" still fills up to it.
893
+ colorCount.closest("label").classList.toggle("hidden", color && mode === "fixed");
894
+ }
895
+ colorCount.addEventListener("input", syncMode);
896
+ bgMode.addEventListener("change", syncMode);
897
+ paletteMode.addEventListener("change", syncMode);
898
+ contourMode.addEventListener("change", syncMode);
899
+ gradientFill.addEventListener("change", syncMode);
900
+ renderPalEdit();
901
+ syncMode();
902
+
903
+ ["optcurve", "blackOnWhite", "turnpolicy", "fillHex", "bgMode", "paletteMode", "paletteBias", "layerMode", "bgColor", "contourMode", "gradientFill", "detailTrueColor"].forEach(function (id) {
904
+ $(id).addEventListener("change", schedule);
905
+ });
906
+
907
+ if (typeof EvgBitmapTracer !== "function" || typeof ImageBuffer !== "function") {
908
+ setStatus("Tracer-bundlea ei ladattu (EvgBitmapTracer / ImageBuffer puuttuu).", "bad");
909
+ return;
910
+ }
911
+ setStatus("Valmis. Lataa kuva tai kokeile esimerkkiä.");
912
+ runBtn.disabled = false;
913
+
914
+ function schedule() {
915
+ if (!sourceBitmap) return;
916
+ clearTimeout(debounceTimer);
917
+ debounceTimer = setTimeout(function () { vectorize(); }, 280);
918
+ }
919
+
920
+ function loadFile(blob, name) {
921
+ var url = URL.createObjectURL(blob);
922
+ var img = new Image();
923
+ img.onload = function () {
924
+ var lim = +maxW.value;
925
+ var w = img.naturalWidth, h = img.naturalHeight;
926
+ if (w > lim) { h = Math.round(h * lim / w); w = lim; }
927
+ var canvas = document.createElement("canvas");
928
+ canvas.width = w; canvas.height = h;
929
+ var ctx = canvas.getContext("2d", { willReadFrequently: true });
930
+ ctx.drawImage(img, 0, 0, w, h);
931
+ var data = ctx.getImageData(0, 0, w, h);
932
+ sourceBitmap = { w: w, h: h, rgba: data.data, name: name || "image" };
933
+ srcStage.classList.remove("empty");
934
+ srcStage.innerHTML = "";
935
+ var preview = document.createElement("img");
936
+ preview.alt = "Lähde";
937
+ preview.src = canvas.toDataURL("image/png");
938
+ srcStage.appendChild(preview);
939
+ URL.revokeObjectURL(url);
940
+ runBtn.disabled = false;
941
+ vectorize();
942
+ };
943
+ img.onerror = function () {
944
+ URL.revokeObjectURL(url);
945
+ setStatus("Kuvan lukeminen epäonnistui.", "bad");
946
+ };
947
+ img.src = url;
948
+ }
949
+
950
+ file.addEventListener("change", function () {
951
+ var f = file.files && file.files[0];
952
+ if (f) loadFile(f, f.name);
953
+ });
954
+
955
+ // Paste an image straight onto the page — a screenshot, or a copy from
956
+ // another tab. Chrome and Safari put a File on the clipboard; Firefox may
957
+ // offer only an image/* string, which is a data: or blob: URL we can fetch.
958
+ function imageFromClipboard(data) {
959
+ if (!data) return null;
960
+ var items = data.items || [];
961
+ for (var i = 0; i < items.length; i++) {
962
+ if (items[i].kind === "file" && /^image\//.test(items[i].type)) {
963
+ var f = items[i].getAsFile();
964
+ if (f) return { blob: f, name: f.name || "leikepöytä.png" };
965
+ }
966
+ }
967
+ var files = data.files || [];
968
+ for (var j = 0; j < files.length; j++) {
969
+ if (/^image\//.test(files[j].type)) {
970
+ return { blob: files[j], name: files[j].name || "leikepöytä.png" };
971
+ }
972
+ }
973
+ return null;
974
+ }
975
+
976
+ document.addEventListener("paste", function (e) {
977
+ // Never steal a paste that belongs to a field the user is typing in.
978
+ var el = document.activeElement;
979
+ if (el && (el.tagName === "INPUT" || el.tagName === "TEXTAREA") &&
980
+ el.type !== "color" && el.type !== "range" && el.type !== "checkbox") {
981
+ return;
982
+ }
983
+ var found = imageFromClipboard(e.clipboardData);
984
+ if (found) {
985
+ e.preventDefault();
986
+ setStatus("Luetaan leikepöydältä…");
987
+ loadFile(found.blob, found.name);
988
+ return;
989
+ }
990
+ // Firefox path: an image/* entry that is a URL rather than a File.
991
+ var items = e.clipboardData ? e.clipboardData.items || [] : [];
992
+ for (var k = 0; k < items.length; k++) {
993
+ if (items[k].kind === "string" && /^image\//.test(items[k].type)) {
994
+ e.preventDefault();
995
+ items[k].getAsString(function (str) {
996
+ setStatus("Luetaan leikepöydältä…");
997
+ fetch(str).then(function (r) { return r.blob(); })
998
+ .then(function (b) { loadFile(b, "leikepöytä.png"); })
999
+ .catch(function () { setStatus("Leikepöydän kuvaa ei saatu luettua.", "bad"); });
1000
+ });
1001
+ return;
1002
+ }
1003
+ }
1004
+ });
1005
+
1006
+ sample.addEventListener("click", function () {
1007
+ fetch("sample.png").then(function (r) {
1008
+ if (!r.ok) throw new Error("HTTP " + r.status);
1009
+ return r.blob();
1010
+ }).then(function (b) { loadFile(b, "sample.png"); })
1011
+ .catch(function (e) { setStatus("Esimerkkikuvaa ei saatu: " + e.message, "bad"); });
1012
+ });
1013
+
1014
+ // --- pre-processing controls -------------------------------------------
1015
+ var preGray = $("preGray"), preInvert = $("preInvert"), preMedian = $("preMedian");
1016
+ var preBright = $("preBright"), preContrast = $("preContrast"), preGamma = $("preGamma");
1017
+ var preSat = $("preSat"), preBlur = $("preBlur"), preSharpen = $("preSharpen");
1018
+ var prePost = $("prePost"), prePreset = $("prePreset");
1019
+
1020
+ // Undo steps over settings, not over pixels: the chain is a set of values, so
1021
+ // stepping back one is putting the previous set of values back. Snapshots are
1022
+ // taken on "change" rather than "input", so dragging a slider from one place
1023
+ // to another is one step to undo rather than forty.
1024
+ var PRE_IDS = ["preGray", "preInvert", "preMedian", "preBright", "preContrast",
1025
+ "preGamma", "preSat", "preBlur", "preSharpen", "prePost"];
1026
+
1027
+ function preSnapshot() {
1028
+ var q = {};
1029
+ PRE_IDS.forEach(function (id) {
1030
+ var el = $(id);
1031
+ q[id] = el.type === "checkbox" ? el.checked : el.value;
1032
+ });
1033
+ return q;
1034
+ }
1035
+
1036
+ function preApply(q) {
1037
+ PRE_IDS.forEach(function (id) {
1038
+ var el = $(id);
1039
+ if (el.type === "checkbox") el.checked = q[id];
1040
+ else { el.value = q[id]; el.dispatchEvent(new Event("input", { bubbles: true })); }
1041
+ });
1042
+ }
1043
+
1044
+ var preStack = [], prePrev = null;
1045
+
1046
+ function preRemember() {
1047
+ if (prePrev) preStack.push(prePrev);
1048
+ prePrev = preSnapshot();
1049
+ $("preUndo").disabled = preStack.length === 0;
1050
+ }
1051
+
1052
+
1053
+ [[preBright, "v-preBright"], [preContrast, "v-preContrast"], [preGamma, "v-preGamma"],
1054
+ [preSat, "v-preSat"], [preBlur, "v-preBlur"], [preSharpen, "v-preSharpen"],
1055
+ [prePost, "v-prePost"]].forEach(function (pair) {
1056
+ bindRange(pair[0], $(pair[1]));
1057
+ });
1058
+ [preGray, preInvert, preMedian].forEach(function (el) {
1059
+ el.addEventListener("change", function () { preRemember(); schedule(); });
1060
+ });
1061
+ PRE_IDS.forEach(function (id) {
1062
+ var el = $(id);
1063
+ if (el.type !== "checkbox") el.addEventListener("change", preRemember);
1064
+ });
1065
+
1066
+ $("preUndo").addEventListener("click", function () {
1067
+ var q = preStack.pop();
1068
+ if (!q) return;
1069
+ preApply(q);
1070
+ prePrev = preSnapshot();
1071
+ $("preUndo").disabled = preStack.length === 0;
1072
+ schedule();
1073
+ });
1074
+
1075
+ $("preReset").addEventListener("click", function () {
1076
+ preRemember();
1077
+ prePreset.value = "none";
1078
+ preApply(PRE_SNAP_NONE);
1079
+ schedule();
1080
+ });
1081
+
1082
+ var PRE_PRESETS = {
1083
+ none: { gray: false, invert: false, median: false, bright: 0, contrast: 0, gamma: 100, sat: 100, blur: 0, sharpen: 0, post: 0 },
1084
+ logo: { gray: false, invert: false, median: true, bright: 0, contrast: 60, gamma: 100, sat: 120, blur: 1, sharpen: 0, post: 0 },
1085
+ lineart: { gray: true, invert: false, median: true, bright: 0, contrast: 80, gamma: 90, sat: 100, blur: 0, sharpen: 60, post: 0 },
1086
+ photo: { gray: false, invert: false, median: true, bright: 0, contrast: 10, gamma: 100, sat: 100, blur: 2, sharpen: 0, post: 0 },
1087
+ poster: { gray: false, invert: false, median: false, bright: 0, contrast: 30, gamma: 100, sat: 130, blur: 1, sharpen: 0, post: 5 }
1088
+ };
1089
+
1090
+ var PRE_SNAP_NONE = { preGray: false, preInvert: false, preMedian: false,
1091
+ preBright: "0", preContrast: "0", preGamma: "100", preSat: "100",
1092
+ preBlur: "0", preSharpen: "0", prePost: "0" };
1093
+ prePrev = preSnapshot();
1094
+
1095
+ prePreset.addEventListener("change", function () {
1096
+ var q = PRE_PRESETS[prePreset.value];
1097
+ if (!q) return;
1098
+ preRemember();
1099
+ preGray.checked = q.gray; preInvert.checked = q.invert; preMedian.checked = q.median;
1100
+ preBright.value = q.bright; preContrast.value = q.contrast; preGamma.value = q.gamma;
1101
+ preSat.value = q.sat; preBlur.value = q.blur; preSharpen.value = q.sharpen;
1102
+ prePost.value = q.post;
1103
+ [preBright, preContrast, preGamma, preSat, preBlur, preSharpen, prePost].forEach(function (el) {
1104
+ el.dispatchEvent(new Event("input", { bubbles: true }));
1105
+ });
1106
+ });
1107
+
1108
+ // The pixels everything downstream reads. Pre-processing edits the picture,
1109
+ // so the tracer, the refiner and anything else that samples the bitmap have
1110
+ // to sample the edited one — the refiner did not, and re-traced a blurred
1111
+ // picture's patch out of the original, which puts detail back exactly where
1112
+ // the filters were asked to take it away.
1113
+ function workingPixels() {
1114
+ return processedBitmap || sourceBitmap.rgba;
1115
+ }
1116
+
1117
+ function preOptions() {
1118
+ return {
1119
+ grayscale: preGray.checked, invert: preInvert.checked, median: preMedian.checked,
1120
+ brightness: +preBright.value, contrast: +preContrast.value, gamma: +preGamma.value,
1121
+ saturation: +preSat.value, blur: +preBlur.value, sharpen: +preSharpen.value,
1122
+ posterize: +prePost.value
1123
+ };
1124
+ }
1125
+
1126
+ function preIsIdentity(o) {
1127
+ return !o.grayscale && !o.invert && !o.median && o.brightness === 0 && o.contrast === 0
1128
+ && o.gamma === 100 && o.saturation === 100 && o.blur === 0 && o.sharpen === 0
1129
+ && o.posterize === 0;
1130
+ }
1131
+
1132
+ // Run the chain and show the result *as* the picture. There is one image, not
1133
+ // an original and a copy of it side by side: the filters edit what you are
1134
+ // working on, and the original is a click away rather than a pane away. Two
1135
+ // panes of the same photograph cost more room than they explain.
1136
+ function buildProcessed() {
1137
+ if (!sourceBitmap) { processedBitmap = null; return; }
1138
+ var o = preOptions();
1139
+ processedBitmap = preIsIdentity(o) ? null
1140
+ : preprocess(sourceBitmap.rgba, sourceBitmap.w, sourceBitmap.h, o);
1141
+ var cv = document.createElement("canvas");
1142
+ cv.width = sourceBitmap.w; cv.height = sourceBitmap.h;
1143
+ cv.getContext("2d").putImageData(
1144
+ new ImageData(processedBitmap || sourceBitmap.rgba, sourceBitmap.w, sourceBitmap.h), 0, 0);
1145
+ srcStage.classList.remove("empty");
1146
+ srcStage.innerHTML = "";
1147
+ var im = document.createElement("img");
1148
+ im.alt = "Lähde";
1149
+ im.src = cv.toDataURL("image/png");
1150
+ srcStage.appendChild(im);
1151
+ $("preNote").textContent = processedBitmap
1152
+ ? "Muokattu — Palauta alkuperäinen tuo sen takaisin."
1153
+ : "Kuva on tämä. Säätimet muokkaavat sitä.";
1154
+ }
1155
+
1156
+ $("preToggle").addEventListener("click", function () {
1157
+ var on = $("prebar").classList.toggle("on");
1158
+ $("preToggle").classList.toggle("active", on);
1159
+ });
1160
+
1161
+ runBtn.addEventListener("click", function () { vectorize(); });
1162
+
1163
+ // ---------------------------------------------------------------- edit mode
1164
+ //
1165
+ // Two tools over the traced result. "Yhdistä" repaints a shape in a chosen
1166
+ // color, which is how a shape merges into its neighbours: adjacent areas of
1167
+ // one fill read as one surface, so recoloring the red knuckles of a hand to
1168
+ // the skin beside them makes the hand one shape. "Tarkenna" is the inverse —
1169
+ // it re-traces a small box around the click with a palette built from that
1170
+ // box alone, the same idea as the detail neighbourhoods, and lays the result
1171
+ // on top.
1172
+ // Tarkennin is what a person reaches for first: it is the tool that adds
1173
+ // something to the picture, where the others take things away.
1174
+ var editOn = false, editTool = "refine", picking = false, editStack = [];
1175
+ var editToggle = $("editToggle"), editbar = $("editbar");
1176
+ var toolMerge = $("toolMerge"), toolRefine = $("toolRefine"), toolPick = $("toolPick");
1177
+ var toolWand = $("toolWand"), wandTol = $("wandTol"), wandCrop = $("wandCrop");
1178
+ var editColor = $("editColor"), refineSize = $("refineSize"), editNote = $("editNote");
1179
+
1180
+ // A traced layer is one path holding every ring of its color, so clicking it
1181
+ // selects a whole color rather than a shape. Edit mode splits each path into
1182
+ // one path per outer ring, carrying the rings nested inside it as its holes —
1183
+ // without that grouping the holes would be filled in and every eye would
1184
+ // close. Containment is decided by bounding box, which is what "inside" means
1185
+ // for rings that came from one traced region and cannot partially overlap.
1186
+ function ringsOf(d) {
1187
+ var out = [], parts = d.split(/(?=M)/);
1188
+ for (var i = 0; i < parts.length; i++) {
1189
+ var seg = parts[i].trim();
1190
+ if (!seg) continue;
1191
+ var nums = seg.match(/-?\d+(?:\.\d+)?/g);
1192
+ if (!nums || nums.length < 4) { out.push({ d: seg, box: null }); continue; }
1193
+ var x0 = Infinity, y0 = Infinity, x1 = -Infinity, y1 = -Infinity;
1194
+ for (var k = 0; k + 1 < nums.length; k += 2) {
1195
+ var x = +nums[k], y = +nums[k + 1];
1196
+ if (x < x0) x0 = x; if (x > x1) x1 = x;
1197
+ if (y < y0) y0 = y; if (y > y1) y1 = y;
1198
+ }
1199
+ out.push({ d: seg, box: [x0, y0, x1, y1], area: (x1 - x0) * (y1 - y0) });
1200
+ }
1201
+ return out;
1202
+ }
1203
+
1204
+ // Which ring contains which. A bounding box is not the answer: under evenodd
1205
+ // it is the *parity* of how many rings enclose a ring that decides whether it
1206
+ // is painted or punched out, and a box test both misses an island sitting
1207
+ // inside a hole and invents containment between rings that merely overlap in
1208
+ // their extents. Entering edit mode with the box version repainted 1472
1209
+ // pixels of a portrait before a single edit was made — a stacked layer is one
1210
+ // frame-filling ring with hundreds of rings inside it, and getting their
1211
+ // nesting wrong exposes the layer underneath. Path2D with an exact
1212
+ // point-in-fill test costs a few milliseconds once and is simply correct.
1213
+ function nestDepths(rings, ctx) {
1214
+ var paths = rings.map(function (r) { return new Path2D(r.d); });
1215
+ var pt = rings.map(function (r) {
1216
+ var m = r.d.match(/-?\d+(?:\.\d+)?/g);
1217
+ return m && m.length >= 2 ? { x: +m[0], y: +m[1] } : null;
1218
+ });
1219
+ return rings.map(function (r, i) {
1220
+ if (!pt[i]) return { depth: 0, parent: -1 };
1221
+ var depth = 0, parent = -1, bestArea = Infinity;
1222
+ for (var j = 0; j < rings.length; j++) {
1223
+ if (j === i) continue;
1224
+ if (!ctx.isPointInPath(paths[j], pt[i].x, pt[i].y, "nonzero")) continue;
1225
+ depth++;
1226
+ var a = rings[j].area === undefined ? Infinity : rings[j].area;
1227
+ if (a < bestArea) { bestArea = a; parent = j; } // the immediate parent
1228
+ }
1229
+ return { depth: depth, parent: parent };
1230
+ });
1231
+ }
1232
+
1233
+ // One layer's path data as individual shapes, each carrying its own holes.
1234
+ // Even depth is painted, odd depth is punched out. A shape carries the
1235
+ // odd-depth rings whose immediate parent it is; an island at depth 2 is a
1236
+ // shape of its own, drawn after the hole it sits in. Splitting on M alone and
1237
+ // treating every ring as a shape fills in every hole — every eye closes —
1238
+ // which is why this is one function used everywhere shapes are split.
1239
+ function splitShapes(d, ctx) {
1240
+ var rings = ringsOf(d || "");
1241
+ if (rings.length < 2) return null;
1242
+ var nest = nestDepths(rings, ctx);
1243
+ var order = [];
1244
+ rings.forEach(function (r, i) { if (nest[i].depth % 2 === 0) order.push(i); });
1245
+ order.sort(function (x, y) { return nest[x].depth - nest[y].depth; });
1246
+ return order.map(function (i) {
1247
+ var out = rings[i].d, box = rings[i].box;
1248
+ for (var j = 0; j < rings.length; j++) {
1249
+ if (nest[j].depth % 2 === 1 && nest[j].parent === i) out += " " + rings[j].d;
1250
+ }
1251
+ return { d: out, box: box };
1252
+ });
1253
+ }
1254
+
1255
+ function explode(svg) {
1256
+ var ctx = document.createElement("canvas").getContext("2d");
1257
+ var paths = Array.prototype.slice.call(svg.querySelectorAll("path"));
1258
+ paths.forEach(function (p) {
1259
+ var shapes = splitShapes(p.getAttribute("d") || "", ctx);
1260
+ if (!shapes || !shapes.length) return;
1261
+ shapes.forEach(function (sh) {
1262
+ var el = document.createElementNS("http://www.w3.org/2000/svg", "path");
1263
+ el.setAttribute("d", sh.d);
1264
+ el.setAttribute("fill", p.getAttribute("fill") || "#000");
1265
+ el.setAttribute("fill-rule", "evenodd");
1266
+ p.parentNode.insertBefore(el, p);
1267
+ });
1268
+ p.parentNode.removeChild(p);
1269
+ });
1270
+ }
1271
+
1272
+ function outSvg() { return outStage.querySelector("svg"); }
1273
+
1274
+ // Every edit path already reports what it did here, so this is where the
1275
+ // status line's live half gets refreshed too. Debounced, because the smooth
1276
+ // brush says something on every dab and serializing half a megabyte of SVG a
1277
+ // hundred times a second would make the tool feel worse than the stale number
1278
+ // ever did.
1279
+ var sizeTimer = 0;
1280
+ function setEditNote(msg) {
1281
+ editNote.textContent = msg;
1282
+ if (sizeTimer) clearTimeout(sizeTimer);
1283
+ sizeTimer = setTimeout(function () { sizeTimer = 0; refreshSize(); }, 120);
1284
+ }
1285
+
1286
+ function refreshEditUi() {
1287
+ toolMerge.classList.toggle("active", editTool === "merge" && !picking);
1288
+ toolRefine.classList.toggle("active", editTool === "refine" && !picking);
1289
+ $("toolSmooth").classList.toggle("active", editTool === "smooth" && !picking);
1290
+ toolWand.classList.toggle("active", editTool === "wand" && !picking);
1291
+ toolPick.classList.toggle("active", picking);
1292
+ outStage.classList.toggle("picking", picking);
1293
+ outStage.classList.toggle("refining", !picking && editTool === "refine");
1294
+ outStage.classList.toggle("smoothing", !picking && editTool === "smooth");
1295
+ outStage.classList.toggle("wanding", !picking && editTool === "wand");
1296
+ editbar.classList.toggle("wand", editTool === "wand" && !picking);
1297
+ $("toolHelp").textContent = picking ? "Napsauta muotoa poimiaksesi sen värin."
1298
+ : editTool === "merge" ? "Napsauta muotoja: ne saavat valitun värin ja sulautuvat naapureihinsa."
1299
+ : editTool === "refine" ? "Vedä alueen yli: se vektoroidaan uudelleen omalla paletillaan."
1300
+ : editTool === "wand" ? "Napsauta kohdetta, tai piirrä sen ympäri suurin piirtein — valinta napsahtaa kuvan omiin rajoihin. ⇧ lisää, ⌥ poistaa. Eristä leikkaa muun pois."
1301
+ : "Vedä ääriviivan yli: kulmat pyöristyvät, pienet muodot katoavat.";
1302
+ }
1303
+
1304
+ // ------------------------------------------------------------ taikasauva
1305
+ //
1306
+ // Pick an object out of the drawing and cut everything else away — an icon
1307
+ // out of a photograph. Click a shape and the selection grows outward from it
1308
+ // by color, or draw a rough outline by hand and let the selection snap to
1309
+ // the real boundaries inside it. ⇧ adds, ⌥ removes.
1310
+ //
1311
+ // Everything here works on what is *visible*, and that is not a detail. A
1312
+ // traced picture is stacked: a lower layer's shape is the union of itself
1313
+ // and everything painted on top of it, so on a test image of a box with a
1314
+ // ball overlapping it the blue layer is 240 px wide and includes the ball.
1315
+ // Ask that geometry which shapes touch which and every shape touches the one
1316
+ // under it; delete the shapes on top and the union underneath is uncovered,
1317
+ // which is how cutting out a box returned a box with the ball's bulge on it,
1318
+ // and how cutting a person out of a portrait returned a full rectangle.
1319
+ //
1320
+ // So the drawing is rasterized once, in its own z-order, into a buffer of
1321
+ // shape indices — a z-buffer, giving for every pixel the shape you can
1322
+ // actually see there. Adjacency, "how much of this shape is inside the line
1323
+ // you drew", the enclosure rule and the crop box all read that buffer, and
1324
+ // the cut carries the z-order into a mask so nothing hidden comes back.
1325
+ //
1326
+ // Canvas antialiases, and a blended pixel between two index colors decodes
1327
+ // to a third, unrelated shape. So the raster is drawn twice under two
1328
+ // different index encodings and a pixel is believed only when both agree —
1329
+ // a blend cannot survive that, and the few boundary pixels it discards are
1330
+ // boundary pixels.
1331
+ var WAND_RASTER = 768;
1332
+ var WAND_KEY = 0x9E3779;
1333
+ var SVGNS = "http://www.w3.org/2000/svg";
1334
+ var wandSel = null, wandGraph = null, wandSeq = 0;
1335
+ // A shape the drawn line cuts through is kept only where the line kept it.
1336
+ // These hold that: which shapes are clipped, by which <clipPath>, and the
1337
+ // raster of each clip so the enclosure rule and the crop box see the same
1338
+ // picture the viewer will.
1339
+ var wandClipOf = new Map(), wandClipEls = new Map(), wandClipRaster = new Map();
1340
+
1341
+ function wandRgb(fill) {
1342
+ if (!/^#[0-9a-f]{6}$/i.test(fill || "")) return [0, 0, 0];
1343
+ return [parseInt(fill.slice(1, 3), 16), parseInt(fill.slice(3, 5), 16),
1344
+ parseInt(fill.slice(5, 7), 16)];
1345
+ }
1346
+
1347
+ // A path is a shape of the drawing only if it is drawn. The refiner leaves
1348
+ // its stroke shape inside a <mask>, and the cut leaves one of its own.
1349
+ function wandPaths(svg) {
1350
+ return Array.prototype.slice.call(svg.querySelectorAll("path")).filter(function (el) {
1351
+ for (var p = el.parentNode; p && p !== svg; p = p.parentNode) {
1352
+ var t = p.tagName;
1353
+ if (t === "mask" || t === "defs" || t === "clipPath" || t === "filter") return false;
1354
+ }
1355
+ return true;
1356
+ });
1357
+ }
1358
+
1359
+ // Refined patches live under a translate, so their path data is in the
1360
+ // crop's coordinates and not the picture's. Null for a shape with no
1361
+ // transformed ancestor, which is every shape in a plain trace.
1362
+ function wandMatrix(svg, el) {
1363
+ var has = false;
1364
+ for (var p = el.parentNode; p && p !== svg; p = p.parentNode) {
1365
+ if (p.getAttribute && p.getAttribute("transform")) { has = true; break; }
1366
+ }
1367
+ if (!has) return null;
1368
+ try { return svg.getScreenCTM().inverse().multiply(el.getScreenCTM()); }
1369
+ catch (e) { return null; }
1370
+ }
1371
+
1372
+ function wandFrame(svg) {
1373
+ var vb = (svg.getAttribute("viewBox") || "").split(/[\s,]+/).map(Number);
1374
+ if (vb.length !== 4 || !(vb[2] > 0) || !(vb[3] > 0)) {
1375
+ vb = [0, 0, +svg.getAttribute("width") || 1, +svg.getAttribute("height") || 1];
1376
+ }
1377
+ return vb;
1378
+ }
1379
+
1380
+ function wandBuild(svg) {
1381
+ var els = wandPaths(svg);
1382
+ var index = new Map(), rgb = [], mats = [], paths = [];
1383
+ els.forEach(function (el, i) {
1384
+ index.set(el, i);
1385
+ rgb.push(wandRgb(el.getAttribute("fill")));
1386
+ mats.push(wandMatrix(svg, el));
1387
+ try { paths.push(new Path2D(el.getAttribute("d") || "")); } catch (e) { paths.push(null); }
1388
+ });
1389
+ var vb = wandFrame(svg);
1390
+ var sc = Math.min(4, WAND_RASTER / Math.max(vb[2], vb[3]));
1391
+ var cw = Math.max(1, Math.round(vb[2] * sc)), ch = Math.max(1, Math.round(vb[3] * sc));
1392
+ function pass(mul) {
1393
+ var cv = document.createElement("canvas");
1394
+ cv.width = cw; cv.height = ch;
1395
+ var cx = cv.getContext("2d", { willReadFrequently: true });
1396
+ cx.setTransform(sc, 0, 0, sc, -vb[0] * sc, -vb[1] * sc);
1397
+ for (var i = 0; i < els.length; i++) {
1398
+ if (!paths[i]) continue;
1399
+ var code = Math.imul(i + 1, mul) & 0xffffff;
1400
+ cx.fillStyle = "rgb(" + (code & 255) + "," + ((code >> 8) & 255) + "," + ((code >> 16) & 255) + ")";
1401
+ var m = mats[i];
1402
+ if (m) { cx.save(); cx.transform(m.a, m.b, m.c, m.d, m.e, m.f); }
1403
+ cx.fill(paths[i], "evenodd");
1404
+ if (m) cx.restore();
1405
+ }
1406
+ return cx.getImageData(0, 0, cw, ch).data;
1407
+ }
1408
+ var A = pass(1), B = pass(WAND_KEY);
1409
+ var n = cw * ch, ids = new Int32Array(n), seen = new Int32Array(els.length);
1410
+ for (var p = 0; p < n; p++) {
1411
+ var o = p * 4;
1412
+ var a = A[o] | (A[o + 1] << 8) | (A[o + 2] << 16);
1413
+ if (a <= 0 || a > els.length) continue;
1414
+ var b = B[o] | (B[o + 1] << 8) | (B[o + 2] << 16);
1415
+ if ((Math.imul(a, WAND_KEY) & 0xffffff) !== b) continue; // an antialiased blend
1416
+ ids[p] = a;
1417
+ seen[a - 1]++;
1418
+ }
1419
+ // Who touches whom, as seen rather than as drawn: two shapes are
1420
+ // neighbours when they meet in the z-buffer, so a boundary buried under a
1421
+ // third shape does not count as contact.
1422
+ var nb = new Array(els.length);
1423
+ function link(x, y) {
1424
+ if (x === y || !x || !y) return;
1425
+ var s = nb[x - 1] || (nb[x - 1] = new Set());
1426
+ s.add(y - 1);
1427
+ s = nb[y - 1] || (nb[y - 1] = new Set());
1428
+ s.add(x - 1);
1429
+ }
1430
+ for (var yy = 0; yy < ch; yy++) {
1431
+ var row = yy * cw;
1432
+ for (var xx = 0; xx < cw; xx++) {
1433
+ var id = ids[row + xx];
1434
+ if (xx + 1 < cw) link(id, ids[row + xx + 1]);
1435
+ if (yy + 1 < ch) link(id, ids[row + cw + xx]);
1436
+ }
1437
+ }
1438
+ return { svg: svg, els: els, index: index, rgb: rgb, mats: mats, ids: ids,
1439
+ cw: cw, ch: ch, sc: sc, vb: vb, nb: nb, seen: seen, n: els.length };
1440
+ }
1441
+
1442
+ function wandInvalidate() { wandGraph = null; }
1443
+
1444
+ function wandClear() {
1445
+ if (wandGraph) {
1446
+ wandGraph.els.forEach(function (el) {
1447
+ el.classList.remove("wand-off");
1448
+ el.removeAttribute("clip-path");
1449
+ });
1450
+ }
1451
+ wandClipEls.forEach(function (el) { if (el.parentNode) el.parentNode.removeChild(el); });
1452
+ wandClipOf = new Map();
1453
+ wandClipEls = new Map();
1454
+ wandClipRaster = new Map();
1455
+ wandSel = null;
1456
+ }
1457
+
1458
+ // Where the selection actually lands on screen: its shapes, minus the parts
1459
+ // a drawn line cut away.
1460
+ function wandCells(g, sel) {
1461
+ var n = g.cw * g.ch, eff = new Uint8Array(n);
1462
+ for (var p = 0; p < n; p++) {
1463
+ var id = g.ids[p];
1464
+ if (!id || !sel.has(id - 1)) continue;
1465
+ var clip = wandClipOf.get(id - 1);
1466
+ if (clip !== undefined) {
1467
+ var r = wandClipRaster.get(clip);
1468
+ if (r && !r[p]) continue;
1469
+ }
1470
+ eff[p] = 1;
1471
+ }
1472
+ return eff;
1473
+ }
1474
+
1475
+ function wandReady(svg) {
1476
+ if (wandGraph && wandGraph.svg === svg &&
1477
+ wandGraph.n === wandPaths(svg).length) return wandGraph;
1478
+ wandClear();
1479
+ wandGraph = wandBuild(svg);
1480
+ return wandGraph;
1481
+ }
1482
+
1483
+ // What the selection actually covers on screen, and what it surrounds. The
1484
+ // holes matter: a color flood cannot reach the shapes painted *on top of*
1485
+ // the object — that is what tolerance is for — but the eyes and the tie are
1486
+ // the object, and what they have in common is that the object closes around
1487
+ // them. A shape that merely overlaps, like a ball crossing a box, reaches
1488
+ // the outside and is left alone.
1489
+ function wandEnclose(g, sel) {
1490
+ var n = g.cw * g.ch, v = wandCells(g, sel), covered = 0;
1491
+ for (var p = 0; p < n; p++) if (v[p]) covered++;
1492
+ if (!covered) return;
1493
+ var out = new Uint8Array(n), stack = [];
1494
+ function push(q) { if (!out[q] && !v[q]) { out[q] = 1; stack.push(q); } }
1495
+ for (var x = 0; x < g.cw; x++) { push(x); push((g.ch - 1) * g.cw + x); }
1496
+ for (var y = 0; y < g.ch; y++) { push(y * g.cw); push(y * g.cw + g.cw - 1); }
1497
+ while (stack.length) {
1498
+ var q2 = stack.pop(), qx = q2 % g.cw, qy = (q2 / g.cw) | 0;
1499
+ if (qx > 0) push(q2 - 1);
1500
+ if (qx + 1 < g.cw) push(q2 + 1);
1501
+ if (qy > 0) push(q2 - g.cw);
1502
+ if (qy + 1 < g.ch) push(q2 + g.cw);
1503
+ }
1504
+ var filled = covered;
1505
+ for (var p2 = 0; p2 < n; p2++) if (!v[p2] && !out[p2]) { v[p2] = 1; filled++; }
1506
+ // "Inside the object" stops meaning anything once the object is the
1507
+ // picture. Clicking a portrait's background covers most of the frame and
1508
+ // surrounds the person; closing over that hands back the whole drawing.
1509
+ if (filled > n * 0.8) return;
1510
+ var inside = new Int32Array(g.n);
1511
+ for (var p3 = 0; p3 < n; p3++) {
1512
+ var id3 = g.ids[p3];
1513
+ if (id3 && v[p3]) inside[id3 - 1]++;
1514
+ }
1515
+ for (var i = 0; i < g.n; i++) {
1516
+ if (sel.has(i) || !g.seen[i]) continue;
1517
+ if (inside[i] >= g.seen[i] * 0.9) sel.add(i);
1518
+ }
1519
+ }
1520
+
1521
+ // Grow from one shape across what touches it and is within tol of the color
1522
+ // that was clicked. Against the seed, not against the shape the flood came
1523
+ // from: chaining tolerance from neighbour to neighbour walks a portrait's
1524
+ // cheek into the wall behind it one imperceptible step at a time.
1525
+ function wandGrow(g, seed, tol) {
1526
+ var want = g.rgb[seed], lim = tol * tol;
1527
+ // A colour flood answers for whole shapes, so a shape it reaches stops
1528
+ // being clipped by whatever line was drawn before.
1529
+ var hit = new Set([seed]), queue = [seed];
1530
+ while (queue.length) {
1531
+ var near = g.nb[queue.pop()];
1532
+ if (!near) continue;
1533
+ near.forEach(function (j) {
1534
+ if (hit.has(j)) return;
1535
+ var a = g.rgb[j];
1536
+ var dr = a[0] - want[0], dg = a[1] - want[1], db = a[2] - want[2];
1537
+ if (dr * dr + dg * dg + db * db > lim) return;
1538
+ hit.add(j);
1539
+ queue.push(j);
1540
+ });
1541
+ }
1542
+ wandEnclose(g, hit);
1543
+ return hit;
1544
+ }
1545
+
1546
+ // The hand-drawn version. A photograph has no color boundary that agrees
1547
+ // with the object — a suit and the curtain behind it are the same tone — so
1548
+ // the line you draw says *where*, roughly, and the shapes say *exactly*: a
1549
+ // shape joins the object when more than half of what you can see of it falls
1550
+ // inside the line. Nothing is cut along the line itself, so a rough sweep
1551
+ // still comes out on the picture's own edges.
1552
+ function wandLassoSelect(g, pts) {
1553
+ var cv = document.createElement("canvas");
1554
+ cv.width = g.cw; cv.height = g.ch;
1555
+ var cx = cv.getContext("2d", { willReadFrequently: true });
1556
+ cx.setTransform(g.sc, 0, 0, g.sc, -g.vb[0] * g.sc, -g.vb[1] * g.sc);
1557
+ var poly = "M" + pts.map(function (p) { return p.x + "," + p.y; }).join("L") + "Z";
1558
+ cx.fillStyle = "#fff";
1559
+ cx.fill(new Path2D(poly));
1560
+ var d = cx.getImageData(0, 0, g.cw, g.ch).data;
1561
+ var n = g.cw * g.ch, mark = new Uint8Array(n), inside = new Int32Array(g.n);
1562
+ for (var p = 0; p < n; p++) {
1563
+ if (d[p * 4 + 3] <= 128) continue;
1564
+ mark[p] = 1;
1565
+ var id = g.ids[p];
1566
+ if (id) inside[id - 1]++;
1567
+ }
1568
+
1569
+ // Two thresholds, because a shape is admitted on one question and kept
1570
+ // whole on another. Admitted: does two fifths of what you can see of it
1571
+ // fall inside the line — below that the shape belongs to the background
1572
+ // and even the part of it inside the line goes, which is what clears the
1573
+ // curtain and the flag and the desk out from behind a person. Kept whole:
1574
+ // is it almost entirely inside, in which case the line was a rough guess
1575
+ // and the shape's own edge is the better answer. In between the line is
1576
+ // the answer, and the shape is kept only where the line kept it.
1577
+ //
1578
+ // Taking every admitted shape whole was the first version and it leaked
1579
+ // badly: one large background shape reaching across the line drags its
1580
+ // whole visible area in, and then the enclosure rule closes over
1581
+ // everything that shape surrounds — 178 shapes selected, 231 after
1582
+ // closing, and 72 of those sat outside the line altogether.
1583
+ var hit = new Set(), part = [];
1584
+ for (var k = 0; k < g.n; k++) {
1585
+ if (!g.seen[k] || inside[k] < g.seen[k] * 0.4) continue;
1586
+ hit.add(k);
1587
+ if (inside[k] < g.seen[k] * 0.85) part.push(k);
1588
+ }
1589
+
1590
+ if (part.length) {
1591
+ var svg = g.svg;
1592
+ var cid = "wandclip" + (++wandSeq);
1593
+ var cp = document.createElementNS(SVGNS, "clipPath");
1594
+ cp.setAttribute("id", cid);
1595
+ cp.setAttribute("clipPathUnits", "userSpaceOnUse");
1596
+ var pe = document.createElementNS(SVGNS, "path");
1597
+ pe.setAttribute("d", poly);
1598
+ cp.appendChild(pe);
1599
+ svg.appendChild(cp);
1600
+ wandClipEls.set(cid, cp);
1601
+ wandClipRaster.set(cid, mark);
1602
+ part.forEach(function (k) { wandClipOf.set(k, cid); });
1603
+ }
1604
+ wandEnclose(g, hit);
1605
+ return hit;
1606
+ }
1607
+
1608
+ function wandPaint() {
1609
+ if (!wandGraph) return;
1610
+ wandGraph.els.forEach(function (el, i) {
1611
+ el.classList.toggle("wand-off", !!wandSel && !wandSel.has(i));
1612
+ var clip = wandSel && wandSel.has(i) ? wandClipOf.get(i) : undefined;
1613
+ if (clip === undefined) el.removeAttribute("clip-path");
1614
+ else el.setAttribute("clip-path", "url(#" + clip + ")");
1615
+ });
1616
+ }
1617
+
1618
+ function wandMerge(g, grown, ev, clipped) {
1619
+ if (!wandSel || (!ev.shiftKey && !ev.altKey)) wandSel = new Set();
1620
+ grown.forEach(function (i) {
1621
+ if (ev.altKey) { wandSel.delete(i); wandClipOf.delete(i); return; }
1622
+ wandSel.add(i);
1623
+ if (!clipped) wandClipOf.delete(i);
1624
+ });
1625
+ wandPaint();
1626
+ setEditNote(wandSel.size + " / " + g.n + " muotoa valittu. ⇧ lisää, ⌥ poistaa, Eristä leikkaa muun pois.");
1627
+ }
1628
+
1629
+ function wandClick(el, ev) {
1630
+ var svg = outSvg();
1631
+ if (!svg) return;
1632
+ var g = wandReady(svg);
1633
+ var seed = g.index.get(el);
1634
+ if (seed === undefined) return;
1635
+ wandMerge(g, wandGrow(g, seed, +wandTol.value), ev, false);
1636
+ }
1637
+
1638
+ function wandLassoDone(pts, ev) {
1639
+ var svg = outSvg();
1640
+ if (!svg) return;
1641
+ var g = wandReady(svg);
1642
+ wandMerge(g, wandLassoSelect(g, pts), ev, true);
1643
+ }
1644
+
1645
+ // The cut. Everything outside the selection leaves the document, which is
1646
+ // real transparency rather than a white shape painted over the rest, and
1647
+ // "rajaa reunoihin" pulls the frame in around what is left so the result is
1648
+ // the size of the icon and not of the photograph it came from.
1649
+ function wandApply() {
1650
+ var svg = outSvg();
1651
+ if (!svg || !wandGraph || !wandSel || !wandSel.size) {
1652
+ setEditNote("Valitse ensin kohde taikasauvalla."); return;
1653
+ }
1654
+ var g = wandGraph, keep = [], drop = [], lowest = -1;
1655
+ for (var i = 0; i < g.n; i++) {
1656
+ var el = g.els[i];
1657
+ if (wandSel.has(i)) { keep.push({ el: el, i: i }); if (lowest < 0) lowest = i; }
1658
+ else if (el.parentNode) drop.push({ el: el, i: i, parent: el.parentNode, next: el.nextSibling });
1659
+ }
1660
+ if (!drop.length) { setEditNote("Koko kuva on valittu — ei mitään leikattavaa."); return; }
1661
+ var entry = { kind: "isolate", removed: drop, masked: [], maskEl: null,
1662
+ clipped: [], clipEls: [] };
1663
+ // The clips a drawn line put on the shapes it cut through are part of the
1664
+ // result now, so they stop belonging to the live selection and start
1665
+ // belonging to the undo step.
1666
+ wandGraph.els.forEach(function (el, i) {
1667
+ if (wandClipOf.has(i) && wandSel.has(i)) entry.clipped.push(el);
1668
+ });
1669
+ wandClipEls.forEach(function (el) { entry.clipEls.push(el); });
1670
+
1671
+ // Where the selection is visible, in raster cells, which is also the crop.
1672
+ var eff = wandCells(g, wandSel);
1673
+ var x0 = Infinity, y0 = Infinity, x1 = -Infinity, y1 = -Infinity;
1674
+ for (var p = 0; p < g.cw * g.ch; p++) {
1675
+ if (!eff[p]) continue;
1676
+ var px = p % g.cw, py = (p / g.cw) | 0;
1677
+ if (px < x0) x0 = px; if (px > x1) x1 = px;
1678
+ if (py < y0) y0 = py; if (py > y1) y1 = py;
1679
+ }
1680
+
1681
+ // Put the z-order back. Deleting what is on top of a stacked layer
1682
+ // uncovers geometry that was never visible: cutting a box out of a picture
1683
+ // where a ball overlaps it returned the box with the ball's bulge painted
1684
+ // blue, and cutting a person out of a portrait returned a full rectangle.
1685
+ // The mask is the drawing's own order, white where the selection is and
1686
+ // black where a later shape used to cover it, so a kept shape paints
1687
+ // exactly where it painted before and nowhere else.
1688
+ var above = drop.filter(function (r) { return r.i > lowest; });
1689
+ // Only a kept shape with something removed above it can be uncovered, and
1690
+ // only those need the mask — or a white copy of themselves inside it. On a
1691
+ // box with one ball over it that is one shape out of two; it keeps the cut
1692
+ // from carrying a second copy of the whole selection.
1693
+ function boxOf(i) {
1694
+ var el = g.els[i], b;
1695
+ try { b = el.getBBox(); } catch (e) { return null; }
1696
+ if (!b) return null;
1697
+ var m = g.mats[i];
1698
+ if (!m) return [b.x, b.y, b.x + b.width, b.y + b.height];
1699
+ var xs = [], ys = [];
1700
+ [[b.x, b.y], [b.x + b.width, b.y], [b.x, b.y + b.height],
1701
+ [b.x + b.width, b.y + b.height]].forEach(function (c) {
1702
+ xs.push(m.a * c[0] + m.c * c[1] + m.e);
1703
+ ys.push(m.b * c[0] + m.d * c[1] + m.f);
1704
+ });
1705
+ return [Math.min.apply(null, xs), Math.min.apply(null, ys),
1706
+ Math.max.apply(null, xs), Math.max.apply(null, ys)];
1707
+ }
1708
+ var aboveBox = above.map(function (r) { return boxOf(r.i); });
1709
+ keep.forEach(function (k) {
1710
+ var kb = boxOf(k.i);
1711
+ k.covered = !kb ? true : above.some(function (r, j) {
1712
+ var b = aboveBox[j];
1713
+ return r.i > k.i && b &&
1714
+ !(b[2] < kb[0] || b[0] > kb[2] || b[3] < kb[1] || b[1] > kb[3]);
1715
+ });
1716
+ });
1717
+ if (above.length) {
1718
+ var mid = "wandmask" + (++wandSeq);
1719
+ var mask = document.createElementNS(SVGNS, "mask");
1720
+ mask.setAttribute("id", mid);
1721
+ // In user space with an explicit region. A mask's default region is
1722
+ // -10%/120%, and a percentage resolves to a length measured from user
1723
+ // space zero rather than from the viewBox origin — so on a cropped
1724
+ // drawing the region lands in the wrong place and cuts the object in
1725
+ // half along a straight line. The same trap the refiner's filter region
1726
+ // fell into, with the same symptom: geometry disappearing while the tool
1727
+ // reports success.
1728
+ mask.setAttribute("maskUnits", "userSpaceOnUse");
1729
+ var mpad = Math.max(g.vb[2], g.vb[3]);
1730
+ mask.setAttribute("x", String(g.vb[0] - mpad));
1731
+ mask.setAttribute("y", String(g.vb[1] - mpad));
1732
+ mask.setAttribute("width", String(g.vb[2] + mpad * 2));
1733
+ mask.setAttribute("height", String(g.vb[3] + mpad * 2));
1734
+ var items = [];
1735
+ keep.forEach(function (k) { if (k.covered) items.push({ i: k.i, on: true, el: k.el }); });
1736
+ above.forEach(function (r) { items.push({ i: r.i, on: false, rec: r }); });
1737
+ items.sort(function (a, b) { return a.i - b.i; });
1738
+ items.forEach(function (it) {
1739
+ var node;
1740
+ if (it.on) {
1741
+ // The selection stays in the drawing, so its white copy is a copy.
1742
+ node = document.createElementNS(SVGNS, "path");
1743
+ node.setAttribute("d", it.el.getAttribute("d") || "");
1744
+ node.setAttribute("fill-rule", "evenodd");
1745
+ node.setAttribute("fill", "#fff");
1746
+ } else {
1747
+ // What is leaving the drawing is not copied into the mask, it is
1748
+ // moved there: cloning every removed shape doubled the file, and
1749
+ // these shapes have nowhere else to be.
1750
+ node = it.rec.el;
1751
+ it.rec.fill = node.getAttribute("fill");
1752
+ it.rec.intoMask = true;
1753
+ node.setAttribute("fill", "#000");
1754
+ node.classList.remove("wand-off");
1755
+ }
1756
+ var m = g.mats[it.i];
1757
+ if (!m) { mask.appendChild(node); return; }
1758
+ var wrap = document.createElementNS(SVGNS, "g");
1759
+ wrap.setAttribute("transform",
1760
+ "matrix(" + [m.a, m.b, m.c, m.d, m.e, m.f].join(" ") + ")");
1761
+ wrap.appendChild(node);
1762
+ mask.appendChild(wrap);
1763
+ });
1764
+ svg.appendChild(mask);
1765
+ entry.maskEl = mask;
1766
+ keep.forEach(function (k) {
1767
+ // A mask on an element that also carries a transform resolves in the
1768
+ // coordinates that transform establishes, so a refined patch keeps the
1769
+ // mask it already has and paints as it did.
1770
+ if (g.mats[k.i] || !k.covered) return;
1771
+ k.el.setAttribute("mask", "url(#" + mid + ")");
1772
+ entry.masked.push(k.el);
1773
+ });
1774
+ }
1775
+
1776
+ drop.forEach(function (r) { if (!r.intoMask) r.parent.removeChild(r.el); });
1777
+
1778
+ if (wandCrop.checked && x1 >= x0 && y1 >= y0) {
1779
+ entry.vb = { viewBox: svg.getAttribute("viewBox"), w: svg.getAttribute("width"),
1780
+ h: svg.getAttribute("height") };
1781
+ var ux0 = g.vb[0] + x0 / g.sc, uy0 = g.vb[1] + y0 / g.sc;
1782
+ var ux1 = g.vb[0] + (x1 + 1) / g.sc, uy1 = g.vb[1] + (y1 + 1) / g.sc;
1783
+ var pad = Math.max(1, Math.round(Math.max(ux1 - ux0, uy1 - uy0) * 0.02));
1784
+ ux0 = Math.floor(ux0) - pad; uy0 = Math.floor(uy0) - pad;
1785
+ var w = Math.ceil(ux1) - ux0 + pad, h = Math.ceil(uy1) - uy0 + pad;
1786
+ svg.setAttribute("viewBox", ux0 + " " + uy0 + " " + w + " " + h);
1787
+ svg.setAttribute("width", String(w));
1788
+ svg.setAttribute("height", String(h));
1789
+ }
1790
+ editStack.push(entry);
1791
+ // Not wandClear: that would take the clips off the shapes that now need
1792
+ // them. Only the selection's own marks go.
1793
+ g.els.forEach(function (el) { el.classList.remove("wand-off"); });
1794
+ wandSel = null;
1795
+ wandClipOf = new Map();
1796
+ wandClipEls = new Map();
1797
+ wandClipRaster = new Map();
1798
+ wandInvalidate();
1799
+ setEditNote(keep.length + " muotoa jäljellä, muu on läpinäkyvää.");
1800
+ }
1801
+
1802
+
1803
+ function enterEdit() {
1804
+ var svg = outSvg();
1805
+ if (!svg) return;
1806
+ explode(svg);
1807
+ editOn = true;
1808
+ editStack = [];
1809
+ outStage.classList.add("editing");
1810
+ editbar.classList.add("on");
1811
+ editToggle.classList.add("active");
1812
+ editToggle.textContent = "Valmis";
1813
+ wandInvalidate();
1814
+ setEditNote(svg.querySelectorAll("path").length + " muotoa.");
1815
+ refreshEditUi();
1816
+ }
1817
+
1818
+ function leaveEdit() {
1819
+ wandClear();
1820
+ editOn = false;
1821
+ outStage.classList.remove("editing", "picking", "refining", "smoothing", "wanding");
1822
+ editbar.classList.remove("on", "wand");
1823
+ editToggle.classList.remove("active");
1824
+ editToggle.textContent = "Muokkaa";
1825
+ }
1826
+
1827
+ editToggle.addEventListener("click", function () { editOn ? leaveEdit() : enterEdit(); });
1828
+ toolMerge.addEventListener("click", function () { editTool = "merge"; picking = false; wandClear(); refreshEditUi(); });
1829
+ toolRefine.addEventListener("click", function () { editTool = "refine"; picking = false; wandClear(); refreshEditUi(); });
1830
+ $("toolSmooth").addEventListener("click", function () { editTool = "smooth"; picking = false; wandClear(); refreshEditUi(); });
1831
+ toolWand.addEventListener("click", function () { editTool = "wand"; picking = false; refreshEditUi(); });
1832
+ toolPick.addEventListener("click", function () { picking = !picking; refreshEditUi(); });
1833
+ var edgeBlend = $("edgeBlend"), refineExtra = $("refineExtra");
1834
+ // Not bindRange: that one re-traces the image, and re-tracing replaces the
1835
+ // drawing being edited. These only affect the next stroke.
1836
+ (function () {
1837
+ var lab = $("v-refineSize");
1838
+ function sync() { lab.textContent = String(refineSize.value); }
1839
+ refineSize.addEventListener("input", sync);
1840
+ sync();
1841
+ [[edgeBlend, "v-edgeBlend"], [refineExtra, "v-refineExtra"],
1842
+ [wandTol, "v-wandTol"]].forEach(function (pair) {
1843
+ var lab2 = $(pair[1]);
1844
+ function sync2() { lab2.textContent = String(pair[0].value); }
1845
+ pair[0].addEventListener("input", sync2);
1846
+ sync2();
1847
+ });
1848
+ })();
1849
+
1850
+ $("wandCut").addEventListener("click", wandApply);
1851
+
1852
+ outStage.addEventListener("click", function (ev) {
1853
+ if (!editOn) return;
1854
+ var el = ev.target;
1855
+ if (!el || el.tagName !== "path") return;
1856
+ if (picking) {
1857
+ var f = el.getAttribute("fill") || "#000000";
1858
+ editColor.value = /^#[0-9a-f]{6}$/i.test(f) ? f : "#000000";
1859
+ picking = false;
1860
+ refreshEditUi();
1861
+ setEditNote("Väri poimittu: " + editColor.value);
1862
+ return;
1863
+ }
1864
+ if (editTool === "merge") {
1865
+ editStack.push({ kind: "fill", el: el, was: el.getAttribute("fill") });
1866
+ el.setAttribute("fill", editColor.value);
1867
+ wandInvalidate();
1868
+ setEditNote("Yhdistetty väriin " + editColor.value + ".");
1869
+ } else if (editTool === "wand") {
1870
+ if (performance.now() - lassoAte < 700) { lassoAte = 0; return; }
1871
+ wandClick(el, ev);
1872
+ }
1873
+ });
1874
+
1875
+ // Re-trace a box around the click with a palette built from that box alone.
1876
+ // A whole-frame palette is counting the whole frame, so a small feature gets
1877
+ // a swatch or two of it; a box gets all of them, which is the same reasoning
1878
+ // the detail neighbourhoods use, applied where a person points.
1879
+ // ------------------------------------------------------------ tarkennin
1880
+ //
1881
+ // A brush that re-runs the vectorizer over what it is dragged across. The
1882
+ // stroke collects an area; when it ends, the source pixels under that area
1883
+ // are traced again on their own and the result is laid on top, masked to the
1884
+ // stroke so nothing outside what was painted changes.
1885
+ //
1886
+ // Tracing per dab is not an option — one trace is tens of milliseconds and a
1887
+ // drag is hundreds of dabs — so the stroke gathers and the trace happens once
1888
+ // at the end. Quantizing the crop alone is the whole point: a palette counted
1889
+ // over a whole frame gives a small feature a swatch or two, and the same
1890
+ // feature cropped gets all of them. That is what "zoom in on it" means, done
1891
+ // where a person points.
1892
+ var refineStroke = null; // {pts:[], preview:<path>}
1893
+
1894
+ function refineBegin(u) {
1895
+ var svg = outSvg();
1896
+ if (!svg) return;
1897
+ var el = document.createElementNS("http://www.w3.org/2000/svg", "path");
1898
+ el.setAttribute("fill", "none");
1899
+ el.setAttribute("stroke", "#ff2ec4");
1900
+ el.setAttribute("stroke-opacity", "0.35");
1901
+ el.setAttribute("stroke-linecap", "round");
1902
+ el.setAttribute("stroke-linejoin", "round");
1903
+ el.setAttribute("stroke-width", String(Math.max(6, +refineSize.value)));
1904
+ // The preview is a hint, not a target: it must not take the pointer nor
1905
+ // pick up the hover outline every other shape gets.
1906
+ el.setAttribute("pointer-events", "none");
1907
+ svg.appendChild(el);
1908
+ refineStroke = { pts: [], preview: el };
1909
+ refineExtend(u);
1910
+ }
1911
+
1912
+ function strokeD(pts) {
1913
+ var d = "M" + pts[0].x.toFixed(1) + "," + pts[0].y.toFixed(1);
1914
+ for (var i = 1; i < pts.length; i++) d += "L" + pts[i].x.toFixed(1) + "," + pts[i].y.toFixed(1);
1915
+ // A single dab is a dot, and a zero-length line draws nothing even with a
1916
+ // round cap, so give it somewhere to go.
1917
+ if (pts.length === 1) d += "L" + (pts[0].x + 0.01).toFixed(2) + "," + pts[0].y.toFixed(1);
1918
+ return d;
1919
+ }
1920
+
1921
+ function refineExtend(u) {
1922
+ if (!refineStroke) return;
1923
+ var pts = refineStroke.pts, last = pts[pts.length - 1];
1924
+ if (last && Math.abs(last.x - u.x) < 1.5 && Math.abs(last.y - u.y) < 1.5) return;
1925
+ pts.push({ x: u.x, y: u.y });
1926
+ refineStroke.preview.setAttribute("d", strokeD(pts));
1927
+ }
1928
+
1929
+ function refineFinish() {
1930
+ if (!refineStroke) return;
1931
+ var stroke = refineStroke;
1932
+ refineStroke = null;
1933
+ var svg = outSvg();
1934
+ stroke.preview.remove();
1935
+ if (!sourceBitmap || !stroke.pts.length || !svg) return;
1936
+
1937
+ var half = Math.max(3, (+refineSize.value) / 2);
1938
+ var x0 = Infinity, y0 = Infinity, x1 = -Infinity, y1 = -Infinity;
1939
+ stroke.pts.forEach(function (p) {
1940
+ if (p.x - half < x0) x0 = p.x - half;
1941
+ if (p.x + half > x1) x1 = p.x + half;
1942
+ if (p.y - half < y0) y0 = p.y - half;
1943
+ if (p.y + half > y1) y1 = p.y + half;
1944
+ });
1945
+ // Trace more than the mask will show. The crop and the mask used to end on
1946
+ // exactly the same line: the stroke's own half-width bounded both, so every
1947
+ // shape the tracer made was cut flat against the crop rectangle and the
1948
+ // mask revealed precisely that cut — a straight edge lying along the
1949
+ // stroke, at right angles to whatever the picture was actually doing there.
1950
+ // Measured on a horizontal stroke over smooth skin, the boundary row went
1951
+ // from 0.5 of edge energy to 14.9, which is more than the refined area's own
1952
+ // interior. With a margin the tracer sees context past the mask, and its cut
1953
+ // falls where nothing is shown.
1954
+ var pad = Math.ceil(half) + 4;
1955
+ x0 = Math.max(0, Math.floor(x0) - pad); y0 = Math.max(0, Math.floor(y0) - pad);
1956
+ x1 = Math.min(sourceBitmap.w, Math.ceil(x1) + pad); y1 = Math.min(sourceBitmap.h, Math.ceil(y1) + pad);
1957
+ var bw = x1 - x0, bh = y1 - y0;
1958
+ if (bw < 4 || bh < 4) { setEditNote("Liian pieni alue."); return; }
1959
+
1960
+ setEditNote("Vektoroidaan " + bw + "×" + bh + " px…");
1961
+ var img = new ImageBuffer();
1962
+ img.init(bw, bh);
1963
+ var rgba = workingPixels();
1964
+ for (var y = 0; y < bh; y++) {
1965
+ for (var x = 0; x < bw; x++) {
1966
+ var o = (((y + y0) * sourceBitmap.w) + (x + x0)) * 4;
1967
+ img.setPixelRGBA(x, y, rgba[o], rgba[o + 1], rgba[o + 2], rgba[o + 3]);
1968
+ }
1969
+ }
1970
+ // The colors the patch will meet at its border, pinned into its palette.
1971
+ // This is the fix for the seam, and it is a palette problem before it is
1972
+ // anything else: quantized in ignorance of its surroundings the crop
1973
+ // invents its own near-equivalents of the same tones, and every one of them
1974
+ // shows as a line where the two meet. Seeded, the quantizer keeps those
1975
+ // exact tones and spends its remaining swatches on what the crop actually
1976
+ // adds, so the border matches by construction.
1977
+ var border = borderColors(svg, stroke.pts, half);
1978
+ var around = paletteOf(border);
1979
+
1980
+ var o2 = EvgTraceOptions.defaults();
1981
+ // The crop gets more swatches than the frame, which is the point of the
1982
+ // tool: a palette counted over a whole picture spends itself on the large
1983
+ // dull areas, so a feature gets one or two entries and comes back flat.
1984
+ // Counted over the crop alone, and given room, the same feature gets a
1985
+ // range to describe itself with — on top of the pinned border tones.
1986
+ // How many colors the crop may invent beyond the ones pinned from its
1987
+ // border. This is the seam control, and it is a sharp one: measured on a
1988
+ // portrait, quantizing the crop with the border colors alone leaves a seam
1989
+ // 1.2 above what the picture has there naturally, and letting it add even
1990
+ // two of its own takes that to 6.7. Four plus a firm edge snap comes to
1991
+ // 1.6 with half again as much detail as none — that is the default.
1992
+ var extra = +refineExtra.value;
1993
+ if (border.length) {
1994
+ o2.paletteMode = "seeded";
1995
+ o2.paletteHex = border;
1996
+ o2.colorCount = Math.min(28, border.length + extra);
1997
+ } else {
1998
+ o2.colorCount = extra;
1999
+ }
2000
+ o2.bgMode = "none";
2001
+ o2.minRegion = 2;
2002
+ o2.turdsize = +turdsize.value;
2003
+ o2.alphamax = +alphamax.value;
2004
+ o2.optcurve = !!optcurve.checked;
2005
+ o2.opttolerance = +opttolerance.value;
2006
+ o2.turnpolicy = turnpolicy.value;
2007
+ var t0 = performance.now();
2008
+ var sub = EvgBitmapTracer.fromImageBuffer(img, o2);
2009
+ sub.trace();
2010
+ var ms = Math.round(performance.now() - t0);
2011
+ var layers = sub.getLayers ? sub.getLayers() : [];
2012
+ if (!layers.length) { setEditNote("Alueesta ei irronnut muotoja."); return; }
2013
+ var reach = +edgeBlend.value;
2014
+ var blendCtx = document.createElement("canvas").getContext("2d");
2015
+
2016
+ // Mask it to the stroke. A crop is a rectangle and the brush is not, so
2017
+ // pasting the rectangle would rewrite everything the brush went around.
2018
+ // Masks take a stroked path; clip paths do not, which is why this is a mask.
2019
+ var id = "refmask" + (++refineSeq);
2020
+ var mask = document.createElementNS("http://www.w3.org/2000/svg", "mask");
2021
+ mask.setAttribute("id", id);
2022
+ mask.setAttribute("maskUnits", "userSpaceOnUse");
2023
+ // Feather the mask. Even with the crop padded and the palette seeded, the
2024
+ // mask is still a hard cut between refined and unrefined content, and a
2025
+ // hard cut along a straight stroke reads as a drawn line. Blurring the
2026
+ // mask turns the last couple of pixels into a ramp, so the patch arrives
2027
+ // rather than starts.
2028
+ var fid = id + "f";
2029
+ // In user space with an explicit region, not percentages: a filter region
2030
+ // given in percent is measured against the filtered element's bounding box,
2031
+ // and a straight horizontal stroke has a bounding box of zero height. The
2032
+ // region collapses, the mask renders empty, and the whole patch disappears
2033
+ // while the tool reports success — which is exactly what happened, and what
2034
+ // the smoke test's "did any pixel change" check caught.
2035
+ var fpad = Math.ceil(half) + 8;
2036
+ var filt = document.createElementNS("http://www.w3.org/2000/svg", "filter");
2037
+ filt.setAttribute("id", fid);
2038
+ filt.setAttribute("filterUnits", "userSpaceOnUse");
2039
+ filt.setAttribute("x", String(x0 - fpad));
2040
+ filt.setAttribute("y", String(y0 - fpad));
2041
+ filt.setAttribute("width", String(bw + fpad * 2));
2042
+ filt.setAttribute("height", String(bh + fpad * 2));
2043
+ var blur = document.createElementNS("http://www.w3.org/2000/svg", "feGaussianBlur");
2044
+ blur.setAttribute("stdDeviation", String(Math.max(0.8, half / 8).toFixed(2)));
2045
+ filt.appendChild(blur);
2046
+ mask.appendChild(filt);
2047
+ var mp = document.createElementNS("http://www.w3.org/2000/svg", "path");
2048
+ mp.setAttribute("filter", "url(#" + fid + ")");
2049
+ mp.setAttribute("d", strokeD(stroke.pts));
2050
+ mp.setAttribute("fill", "none");
2051
+ mp.setAttribute("stroke", "#fff");
2052
+ mp.setAttribute("stroke-linecap", "round");
2053
+ mp.setAttribute("stroke-linejoin", "round");
2054
+ mp.setAttribute("stroke-width", String(half * 2));
2055
+ mask.appendChild(mp);
2056
+ svg.appendChild(mask);
2057
+
2058
+ // Two groups, not one, and the reason is a real trap: a mask on an element
2059
+ // that also carries a transform is resolved in the coordinate system that
2060
+ // transform establishes, so a mask drawn in image coordinates lands offset
2061
+ // by the crop origin — far enough to miss the shapes entirely, which masked
2062
+ // the whole result away and changed exactly zero pixels. The outer group
2063
+ // wears the mask in image space, the inner one does the translating.
2064
+ var g = document.createElementNS("http://www.w3.org/2000/svg", "g");
2065
+ g.setAttribute("mask", "url(#" + id + ")");
2066
+ var inner = document.createElementNS("http://www.w3.org/2000/svg", "g");
2067
+ inner.setAttribute("transform", "translate(" + x0 + "," + y0 + ")");
2068
+ var snapped = 0;
2069
+ layers.forEach(function (L) {
2070
+ if (!L.pathData) return;
2071
+ var pe = document.createElementNS("http://www.w3.org/2000/svg", "path");
2072
+ pe.setAttribute("d", L.pathData);
2073
+ pe.setAttribute("fill-rule", "evenodd");
2074
+ inner.appendChild(pe);
2075
+ var fill = (L.fillHex || "#000").toLowerCase();
2076
+ pe.setAttribute("fill", fill);
2077
+ // Split the shape's own extent, not the layer's: a layer is every ring of
2078
+ // one color and can span the whole stroke, so one depth for all of it
2079
+ // would blend the middle and the border alike.
2080
+ var shapes = splitShapes(L.pathData, blendCtx);
2081
+ if (shapes && shapes.length) {
2082
+ pe.remove();
2083
+ shapes.forEach(function (sh) {
2084
+ var use = fill;
2085
+ if (sh.box) {
2086
+ var mid = { x: x0 + (sh.box[0] + sh.box[2]) / 2, y: y0 + (sh.box[1] + sh.box[3]) / 2 };
2087
+ use = blendEdge(fill, mid, stroke.pts, half, around, reach);
2088
+ if (use !== fill) snapped++;
2089
+ }
2090
+ var el2 = document.createElementNS("http://www.w3.org/2000/svg", "path");
2091
+ el2.setAttribute("d", sh.d);
2092
+ el2.setAttribute("fill", use);
2093
+ el2.setAttribute("fill-rule", "evenodd");
2094
+ inner.appendChild(el2);
2095
+ });
2096
+ } else {
2097
+ var r1 = ringsOf(L.pathData);
2098
+ if (r1.length === 1 && r1[0].box) {
2099
+ var mid1 = { x: x0 + (r1[0].box[0] + r1[0].box[2]) / 2, y: y0 + (r1[0].box[1] + r1[0].box[3]) / 2 };
2100
+ var use1 = blendEdge(fill, mid1, stroke.pts, half, around, reach);
2101
+ if (use1 !== fill) snapped++;
2102
+ pe.setAttribute("fill", use1);
2103
+ }
2104
+ }
2105
+ });
2106
+ g.appendChild(inner);
2107
+ svg.appendChild(g);
2108
+ editStack.push({ kind: "add", el: g, also: mask });
2109
+ wandInvalidate();
2110
+ setEditNote("Tarkennettu " + bw + "×" + bh + " px — " + layers.length + " sävyä ("
2111
+ + border.length + " reunalta kiinnitettyä), " + snapped + " sulautettu, " + ms + " ms.");
2112
+ }
2113
+
2114
+ // The colors actually on the picture at the border of this stroke.
2115
+ //
2116
+ // Not the whole frame's palette: what the patch has to agree with is the
2117
+ // handful of colors it is about to butt up against, and seeding the crop's
2118
+ // quantizer with those is what makes the border right by construction rather
2119
+ // than by correction afterwards. Sampled by walking a ring just outside the
2120
+ // stroke and asking which shape is on top at each step, which is the same
2121
+ // question the eye asks when it sees a seam.
2122
+ function borderColors(svg, pts, half) {
2123
+ var ctx = document.createElement("canvas").getContext("2d");
2124
+ var paths = Array.prototype.slice.call(svg.querySelectorAll("path"));
2125
+ var cache = paths.map(function (e) {
2126
+ return { fill: (e.getAttribute("fill") || "").toLowerCase(), p2: new Path2D(e.getAttribute("d") || "") };
2127
+ });
2128
+ var tally = {}, out = [];
2129
+ var ring = half + 2;
2130
+ for (var i = 0; i < pts.length; i += 2) {
2131
+ for (var a = 0; a < 12; a++) {
2132
+ var th = a / 12 * Math.PI * 2;
2133
+ var x = pts[i].x + ring * Math.cos(th), y = pts[i].y + ring * Math.sin(th);
2134
+ var top = null;
2135
+ for (var k = 0; k < cache.length; k++) {
2136
+ if (!/^#[0-9a-f]{6}$/.test(cache[k].fill)) continue;
2137
+ if (ctx.isPointInPath(cache[k].p2, x, y, "evenodd")) top = cache[k].fill;
2138
+ }
2139
+ if (top) tally[top] = (tally[top] || 0) + 1;
2140
+ }
2141
+ }
2142
+ Object.keys(tally).forEach(function (h) { out.push({ hex: h, n: tally[h] }); });
2143
+ out.sort(function (p, q) { return q.n - p.n; });
2144
+ return out.map(function (o) { return o.hex; });
2145
+ }
2146
+
2147
+ function paletteOf(hexes) {
2148
+ return hexes.map(function (f) {
2149
+ return { hex: f, r: parseInt(f.slice(1, 3), 16), g: parseInt(f.slice(3, 5), 16), b: parseInt(f.slice(5, 7), 16) };
2150
+ });
2151
+ }
2152
+
2153
+ // How far inside the stroke a point lies: 1 at the centre line, 0 at the edge.
2154
+ function strokeDepth(pt, pts, half) {
2155
+ var best = Infinity;
2156
+ for (var i = 0; i < pts.length; i++) {
2157
+ var a = pts[i], b = pts[i + 1] || pts[i];
2158
+ var vx = b.x - a.x, vy = b.y - a.y, len2 = vx * vx + vy * vy;
2159
+ var t = len2 < 1e-9 ? 0 : Math.max(0, Math.min(1, ((pt.x - a.x) * vx + (pt.y - a.y) * vy) / len2));
2160
+ var dx = pt.x - (a.x + t * vx), dy = pt.y - (a.y + t * vy);
2161
+ var d = dx * dx + dy * dy;
2162
+ if (d < best) best = d;
2163
+ }
2164
+ return Math.max(0, 1 - Math.sqrt(best) / half);
2165
+ }
2166
+
2167
+ // Bring the patch back to the surrounding colors at its border.
2168
+ //
2169
+ // The last of the four, and it still earns its keep. Seeding the palette,
2170
+ // padding the crop and feathering the mask take the boundary line out; this
2171
+ // takes the colour disagreement out. Measured across the boundary as excess
2172
+ // over what the picture has there naturally: 5.5 without it, 0.2 at 90, for
2173
+ // 12.8 → 11.9 of detail. Worth the trade, so 90 is the default. A patch
2174
+ // colour with no near neighbour outside keeps its own whatever this is set
2175
+ // to — that is the case none of the four can fix, and should not.
2176
+ //
2177
+ // A crop quantized on its own has colors the frame's palette does not, which
2178
+ // is exactly why it shows detail — and exactly why its edge is visible where
2179
+ // the two meet. So the tolerance for snapping a patch color onto a
2180
+ // surrounding one is widened toward the border: at the edge almost anything
2181
+ // snaps and the seam closes, deep inside almost nothing does and the detail
2182
+ // survives. A color that has no near neighbour in the surrounding palette
2183
+ // even at the edge keeps its own — sometimes the patch genuinely found
2184
+ // something the frame cannot say, and forcing that to the nearest swatch
2185
+ // would throw the find away.
2186
+ function blendEdge(fill, pt, pts, half, palette, reach) {
2187
+ if (reach <= 0 || !palette.length) return fill;
2188
+ var depth = strokeDepth(pt, pts, half);
2189
+ var tol = reach * (1 - depth);
2190
+ if (tol <= 0) return fill;
2191
+ var r = parseInt(fill.slice(1, 3), 16), g = parseInt(fill.slice(3, 5), 16), b = parseInt(fill.slice(5, 7), 16);
2192
+ var best = null, bestD = Infinity;
2193
+ for (var i = 0; i < palette.length; i++) {
2194
+ var c = palette[i];
2195
+ var d = Math.max(Math.abs(c.r - r), Math.abs(c.g - g), Math.abs(c.b - b));
2196
+ if (d < bestD) { bestD = d; best = c; }
2197
+ }
2198
+ return best && bestD <= tol ? best.hex : fill;
2199
+ }
2200
+
2201
+ var refineSeq = 0;
2202
+
2203
+ // ------------------------------------------------------------ pehmennä
2204
+ //
2205
+ // A brush that takes corners out of whatever it is dragged over. Each dab
2206
+ // pulls every point under it toward the midpoint of its neighbours, which is
2207
+ // what rounds a corner off, and then drops points that have become nearly
2208
+ // collinear, which is what makes the outline simpler rather than merely
2209
+ // rounder. Both are local and both are small, so the tool behaves like a
2210
+ // tool: one pass barely shows, a few passes over the same place take the
2211
+ // detail out, and a small shape loses points and area on every pass until
2212
+ // there is nothing left of it and it goes. It only touches shapes of the
2213
+ // chosen color, so a stroke across an eye can smooth the lashes without
2214
+ // touching the white beside them.
2215
+
2216
+ // The tracer emits M/L/C/Z with comma-separated pairs. Smoothing needs points
2217
+ // on the curve, and a cubic's control points are not on it, so a ring has to
2218
+ // be flattened before it can be worked — but only a ring the brush actually
2219
+ // reaches. Potrace-style curve fitting is far more economical than any list
2220
+ // of points (79 shapes here are 1272 cubics), so flattening a whole shape to
2221
+ // touch one edge of it made the file 2.5x bigger for nothing. Rings the brush
2222
+ // never meets keep their original text verbatim.
2223
+ function flattenRing(d) {
2224
+ var pts = [], cur = null, start = null;
2225
+ var re = /([MLCZ])([^MLCZ]*)/gi, m;
2226
+ function nums(t) { return (t.match(/-?\d+(?:\.\d+)?/g) || []).map(Number); }
2227
+ while ((m = re.exec(d))) {
2228
+ var cmd = m[1].toUpperCase(), arr = nums(m[2]);
2229
+ if (cmd === "M") { cur = { x: arr[0], y: arr[1] }; start = cur; pts.push(cur); }
2230
+ else if (cmd === "L") {
2231
+ for (var i = 0; i + 1 < arr.length; i += 2) { cur = { x: arr[i], y: arr[i+1] }; pts.push(cur); }
2232
+ } else if (cmd === "C") {
2233
+ for (var j = 0; j + 5 < arr.length; j += 6) {
2234
+ var c1 = { x: arr[j], y: arr[j+1] }, c2 = { x: arr[j+2], y: arr[j+3] },
2235
+ p3 = { x: arr[j+4], y: arr[j+5] };
2236
+ // Sample by how long the segment is, not by a fixed count: a curve
2237
+ // three pixels across does not need six points on it.
2238
+ var span = Math.hypot(c1.x-cur.x, c1.y-cur.y) + Math.hypot(c2.x-c1.x, c2.y-c1.y) + Math.hypot(p3.x-c2.x, p3.y-c2.y);
2239
+ var steps = Math.max(1, Math.min(8, Math.round(span / 1.6)));
2240
+ for (var k = 1; k <= steps; k++) {
2241
+ var t = k / steps, u = 1 - t;
2242
+ pts.push({
2243
+ x: u*u*u*cur.x + 3*u*u*t*c1.x + 3*u*t*t*c2.x + t*t*t*p3.x,
2244
+ y: u*u*u*cur.y + 3*u*u*t*c1.y + 3*u*t*t*c2.y + t*t*t*p3.y
2245
+ });
2246
+ }
2247
+ cur = p3;
2248
+ }
2249
+ } else if (cmd === "Z") { cur = start; }
2250
+ }
2251
+ while (pts.length > 2 && Math.abs(pts[0].x - pts[pts.length-1].x) < 1e-6
2252
+ && Math.abs(pts[0].y - pts[pts.length-1].y) < 1e-6) pts.pop();
2253
+ return pts;
2254
+ }
2255
+
2256
+ // Douglas-Peucker. Flattening a ring costs far more than the curve fit it
2257
+ // replaced — potrace describes these shapes in a couple of hundred cubics and
2258
+ // a point list needs thousands — and the whole document pays for it: past
2259
+ // roughly half a megabyte Chromium starts rasterizing an SVG image in
2260
+ // degraded tiles, which shows up as a rectangular patch of blended color that
2261
+ // no single shape accounts for. Dropping points that the straight line
2262
+ // between their neighbours already describes keeps the outline and most of
2263
+ // the size.
2264
+ function simplify(pts, tol) {
2265
+ if (pts.length < 5) return pts;
2266
+ var keep = new Uint8Array(pts.length);
2267
+ keep[0] = 1;
2268
+ keep[pts.length - 1] = 1;
2269
+ var stack = [[0, pts.length - 1]];
2270
+ while (stack.length) {
2271
+ var seg = stack.pop(), i0 = seg[0], i1 = seg[1];
2272
+ var a = pts[i0], b = pts[i1];
2273
+ var vx = b.x - a.x, vy = b.y - a.y, len = Math.hypot(vx, vy);
2274
+ var worst = -1, wd = tol;
2275
+ for (var i = i0 + 1; i < i1; i++) {
2276
+ var p = pts[i];
2277
+ var d = len < 1e-9 ? Math.hypot(p.x - a.x, p.y - a.y)
2278
+ : Math.abs(vx * (a.y - p.y) - vy * (a.x - p.x)) / len;
2279
+ if (d > wd) { wd = d; worst = i; }
2280
+ }
2281
+ if (worst > 0) {
2282
+ keep[worst] = 1;
2283
+ stack.push([i0, worst], [worst, i1]);
2284
+ }
2285
+ }
2286
+ var out = [];
2287
+ for (var k = 0; k < pts.length; k++) if (keep[k]) out.push(pts[k]);
2288
+ return out.length >= 5 ? out : pts;
2289
+ }
2290
+
2291
+ function ringArea(r) {
2292
+ var a = 0;
2293
+ for (var i = 0, n = r.length; i < n; i++) {
2294
+ var p = r[i], q = r[(i + 1) % n];
2295
+ a += p.x * q.y - q.x * p.y;
2296
+ }
2297
+ return Math.abs(a) / 2;
2298
+ }
2299
+
2300
+ // Redraw as curves rather than as the polygon it now is: a Catmull-Rom
2301
+ // through the points, written out as the cubics SVG wants.
2302
+ function curveString(r) {
2303
+ var n = r.length;
2304
+ var d = "M" + r[0].x.toFixed(2) + "," + r[0].y.toFixed(2);
2305
+ for (var i = 0; i < n; i++) {
2306
+ var p0 = r[(i - 1 + n) % n], p1 = r[i], p2 = r[(i + 1) % n], p3 = r[(i + 2) % n];
2307
+ d += "C" + (p1.x + (p2.x - p0.x) / 6).toFixed(2) + "," + (p1.y + (p2.y - p0.y) / 6).toFixed(2)
2308
+ + " " + (p2.x - (p3.x - p1.x) / 6).toFixed(2) + "," + (p2.y - (p3.y - p1.y) / 6).toFixed(2)
2309
+ + " " + p2.x.toFixed(2) + "," + p2.y.toFixed(2);
2310
+ }
2311
+ return d + "Z";
2312
+ }
2313
+
2314
+ function ringsFor(el) {
2315
+ if (!el.__rings) {
2316
+ el.__rings = ringsOf(el.getAttribute("d") || "").map(function (r) {
2317
+ return { d: r.d, box: r.box, pts: null };
2318
+ });
2319
+ }
2320
+ return el.__rings;
2321
+ }
2322
+
2323
+ function writeRings(el) {
2324
+ var d = "";
2325
+ el.__rings.forEach(function (r) { d += (r.pts ? curveString(r.pts) : r.d) + " "; });
2326
+ el.setAttribute("d", d.trim());
2327
+ }
2328
+
2329
+ // Pulling points toward their neighbours closes a narrow neck, and once the
2330
+ // two sides of a neck cross, evenodd turns the inside of the ring into a mesh
2331
+ // of alternating filled and empty slivers — on screen a muddy block of a
2332
+ // color that is in no palette. So a dab that makes the outline cross itself
2333
+ // is refused and the ring is left as it was. Only segments the brush touched
2334
+ // can have started a crossing, and a crossing they start is next to them, so
2335
+ // testing that neighbourhood against itself is enough and stays cheap.
2336
+ function segHit(a, b, c, d) {
2337
+ function side(p, q, r) { return (q.x - p.x) * (r.y - p.y) - (q.y - p.y) * (r.x - p.x); }
2338
+ var d1 = side(c, d, a), d2 = side(c, d, b), d3 = side(a, b, c), d4 = side(a, b, d);
2339
+ return ((d1 > 0) !== (d2 > 0)) && ((d3 > 0) !== (d4 > 0));
2340
+ }
2341
+
2342
+ function selfCrosses(ring, cx, cy, radius) {
2343
+ var n = ring.length, reach = radius * 1.5, r2 = reach * reach, near = [];
2344
+ for (var i = 0; i < n; i++) {
2345
+ var p = ring[i], q = ring[(i + 1) % n];
2346
+ var mx = (p.x + q.x) / 2 - cx, my = (p.y + q.y) / 2 - cy;
2347
+ if (mx * mx + my * my <= r2) near.push(i);
2348
+ if (near.length > 400) break; // stay bounded on a huge ring
2349
+ }
2350
+ for (var a = 0; a < near.length; a++) {
2351
+ var i1 = near[a], p1 = ring[i1], q1 = ring[(i1 + 1) % n];
2352
+ for (var b = a + 1; b < near.length; b++) {
2353
+ var i2 = near[b];
2354
+ if (i2 === i1 || (i2 + 1) % n === i1 || (i1 + 1) % n === i2) continue;
2355
+ if (segHit(p1, q1, ring[i2], ring[(i2 + 1) % n])) return true;
2356
+ }
2357
+ }
2358
+ return false;
2359
+ }
2360
+
2361
+ var SMOOTH_PULL = 0.6; // how far a point moves toward its neighbours' midpoint
2362
+ var SMOOTH_DROP = 0.7; // how flat a corner has to be before it is dropped
2363
+ var SMOOTH_MIN_AREA = 6; // a ring smaller than this has stopped being a shape
2364
+
2365
+ function smoothDab(el, cx, cy, radius) {
2366
+ var rings = ringsFor(el), r2 = radius * radius, changed = false, kept = [];
2367
+ for (var s = 0; s < rings.length; s++) {
2368
+ var rec = rings[s], bx = rec.box;
2369
+ if (bx && (cx < bx[0] - radius || cx > bx[2] + radius || cy < bx[1] - radius || cy > bx[3] + radius)) {
2370
+ kept.push(rec);
2371
+ continue;
2372
+ }
2373
+ if (!rec.pts) rec.pts = flattenRing(rec.d);
2374
+ var ring = rec.pts, n = ring.length;
2375
+ if (n < 5) { kept.push(rec); continue; }
2376
+ var hit = false;
2377
+ var moved = ring.map(function (p, i) {
2378
+ var dx = p.x - cx, dy = p.y - cy, d2 = dx*dx + dy*dy;
2379
+ if (d2 > r2) return p;
2380
+ hit = true;
2381
+ var a = ring[(i - 1 + n) % n], b = ring[(i + 1) % n];
2382
+ var t = SMOOTH_PULL * (1 - Math.sqrt(d2) / radius);
2383
+ return { x: p.x + t * ((a.x + b.x) / 2 - p.x), y: p.y + t * ((a.y + b.y) / 2 - p.y) };
2384
+ });
2385
+ if (!hit) { kept.push(rec); continue; }
2386
+ changed = true;
2387
+ // Drop points that are now nearly on the line between their neighbours.
2388
+ // This is the half that simplifies rather than merely rounds, and it is
2389
+ // what makes a small shape lose itself a piece at a time.
2390
+ var thin = [];
2391
+ for (var i = 0; i < moved.length; i++) {
2392
+ var p = moved[i];
2393
+ var a = thin.length ? thin[thin.length - 1] : moved[(i - 1 + moved.length) % moved.length];
2394
+ var b = moved[(i + 1) % moved.length];
2395
+ var dx2 = p.x - cx, dy2 = p.y - cy;
2396
+ if (dx2*dx2 + dy2*dy2 > r2 || moved.length - (moved.length - thin.length) <= 5) { thin.push(p); continue; }
2397
+ var vx = b.x - a.x, vy = b.y - a.y, len = Math.hypot(vx, vy);
2398
+ var dev = len < 1e-6 ? 0 : Math.abs(vx * (a.y - p.y) - vy * (a.x - p.x)) / len;
2399
+ if (dev > SMOOTH_DROP) thin.push(p);
2400
+ }
2401
+ // A ring that fits inside the brush is one of the small shapes this tool
2402
+ // is meant to be able to erase by dwelling on it. Pulling every point
2403
+ // toward its neighbours shrinks such a ring, but only by a few percent a
2404
+ // pass, so on its own the shape survives far longer than "a moment".
2405
+ // Halving its points as well takes its corners away several at a time,
2406
+ // and it collapses in a handful of passes instead of dozens.
2407
+ var whole = true;
2408
+ for (var w = 0; w < thin.length; w++) {
2409
+ var wx = thin[w].x - cx, wy = thin[w].y - cy;
2410
+ if (wx*wx + wy*wy > r2) { whole = false; break; }
2411
+ }
2412
+ if (whole && thin.length > 8) {
2413
+ var half = [];
2414
+ for (var h = 0; h < thin.length; h += 2) half.push(thin[h]);
2415
+ thin = half;
2416
+ }
2417
+ thin = simplify(thin, 0.8);
2418
+ if (selfCrosses(thin, cx, cy, radius)) { kept.push(rec); continue; }
2419
+ if (thin.length >= 5 && ringArea(thin) >= SMOOTH_MIN_AREA) {
2420
+ rec.pts = thin;
2421
+ rec.box = null; // it moved; stop trusting the old box
2422
+ kept.push(rec);
2423
+ }
2424
+ // else: the ring has shrunk out of existence and is simply not kept
2425
+ }
2426
+ if (!changed) return false;
2427
+ el.__rings = kept;
2428
+ if (!kept.length) {
2429
+ // A shape the brush dissolved is part of the stroke that dissolved it.
2430
+ // Recording it separately made that one stroke cost two clicks to take
2431
+ // back, which is not how a brush is supposed to behave.
2432
+ if (!strokeTouched || !strokeTouched.has(el)) {
2433
+ editStack.push({ kind: "remove", el: el, parent: el.parentNode, next: el.nextSibling });
2434
+ }
2435
+ wandInvalidate();
2436
+ el.parentNode.removeChild(el);
2437
+ return true;
2438
+ }
2439
+ writeRings(el);
2440
+ return true;
2441
+ }
2442
+
2443
+ var brushing = false, strokeTouched = null;
2444
+
2445
+ function toUser(ev, svg) {
2446
+ var vb = (svg.getAttribute("viewBox") || "").split(/[\s,]+/).map(Number);
2447
+ var rect = svg.getBoundingClientRect();
2448
+ var vw = vb.length === 4 ? vb[2] : rect.width, vh = vb.length === 4 ? vb[3] : rect.height;
2449
+ return {
2450
+ x: (ev.clientX - rect.left) / rect.width * vw,
2451
+ y: (ev.clientY - rect.top) / rect.height * vh,
2452
+ scale: vw / rect.width
2453
+ };
2454
+ }
2455
+
2456
+ function brushAt(ev) {
2457
+ var svg = outSvg();
2458
+ if (!svg) return;
2459
+ var u = toUser(ev, svg);
2460
+ var radius = Math.max(3, (+refineSize.value) / 2);
2461
+ var want = editColor.value.toLowerCase();
2462
+ var paths = Array.prototype.slice.call(svg.querySelectorAll("path"));
2463
+ var hits = 0;
2464
+ paths.forEach(function (el) {
2465
+ if ((el.getAttribute("fill") || "").toLowerCase() !== want) return;
2466
+ var b;
2467
+ try { b = el.getBBox(); } catch (e) { return; }
2468
+ if (u.x < b.x - radius || u.x > b.x + b.width + radius ||
2469
+ u.y < b.y - radius || u.y > b.y + b.height + radius) return;
2470
+ // A stroke is one action, so it is one undo step however many shapes it
2471
+ // crosses — recording a shape at a time meant a drag over twenty shapes
2472
+ // took twenty clicks to take back.
2473
+ if (!strokeTouched.has(el)) {
2474
+ strokeTouched.set(el, { d: el.getAttribute("d"), parent: el.parentNode,
2475
+ next: el.nextSibling });
2476
+ }
2477
+ if (smoothDab(el, u.x, u.y, radius)) hits++;
2478
+ });
2479
+ if (hits) setEditNote("Pehmennetään " + want + " — " + hits + " muotoa kosketettu.");
2480
+ }
2481
+
2482
+ function endStroke() {
2483
+ if (!brushing) return;
2484
+ brushing = false;
2485
+ if (strokeTouched && strokeTouched.size) {
2486
+ var was = [];
2487
+ strokeTouched.forEach(function (rec, el) {
2488
+ was.push({ el: el, d: rec.d, parent: rec.parent, next: rec.next });
2489
+ });
2490
+ editStack.push({ kind: "stroke", was: was });
2491
+ wandInvalidate();
2492
+ }
2493
+ strokeTouched = null;
2494
+ }
2495
+
2496
+ // The lasso. A drag in wand mode draws the rough outline; a click still
2497
+ // floods by color, so the two live on the same tool and the gesture picks.
2498
+ // The click that ends a drag is swallowed, or releasing the lasso would
2499
+ // immediately reselect whatever was under the pointer.
2500
+ // A drag ends in a click on whatever was under the pointer, and that click
2501
+ // would immediately reselect it, so the drag swallows it. By time, not by a
2502
+ // flag left standing: a drag that ends off the picture never produces the
2503
+ // click it is waiting for, and the flag then eats the next real one — the
2504
+ // first click of the next selection, and only the first, which is exactly
2505
+ // the kind of thing that gets blamed on the picture rather than the tool.
2506
+ var lassoPts = null, lassoEl = null, lassoAte = 0;
2507
+
2508
+ function lassoDraw() {
2509
+ if (!lassoEl || !lassoPts) return;
2510
+ var d = "M" + lassoPts.map(function (p) { return p.x + "," + p.y; }).join("L");
2511
+ lassoEl.setAttribute("d", d + "Z");
2512
+ }
2513
+
2514
+ function lassoEnd(ev) {
2515
+ var pts = lassoPts, el = lassoEl;
2516
+ lassoPts = null; lassoEl = null;
2517
+ if (el && el.parentNode) el.parentNode.removeChild(el);
2518
+ if (!pts || pts.length < 3) return false;
2519
+ var x0 = Infinity, y0 = Infinity, x1 = -Infinity, y1 = -Infinity;
2520
+ pts.forEach(function (p) {
2521
+ if (p.x < x0) x0 = p.x; if (p.x > x1) x1 = p.x;
2522
+ if (p.y < y0) y0 = p.y; if (p.y > y1) y1 = p.y;
2523
+ });
2524
+ // Too small to be an outline: that was a click, and a click means flood.
2525
+ if (x1 - x0 < 6 && y1 - y0 < 6) return false;
2526
+ wandLassoDone(pts, ev);
2527
+ return true;
2528
+ }
2529
+
2530
+ outStage.addEventListener("pointerdown", function (ev) {
2531
+ if (!editOn || picking) return;
2532
+ var svg = outSvg();
2533
+ if (!svg) return;
2534
+ if (editTool === "wand") {
2535
+ ev.preventDefault();
2536
+ lassoAte = 0;
2537
+ lassoPts = [toUser(ev, svg)];
2538
+ lassoEl = document.createElementNS(SVGNS, "path");
2539
+ lassoEl.setAttribute("fill", "none");
2540
+ lassoEl.setAttribute("stroke", "#ff2ec4");
2541
+ lassoEl.setAttribute("stroke-dasharray", "5 4");
2542
+ lassoEl.setAttribute("stroke-width", "1.5");
2543
+ lassoEl.setAttribute("vector-effect", "non-scaling-stroke");
2544
+ lassoEl.setAttribute("pointer-events", "none");
2545
+ svg.appendChild(lassoEl);
2546
+ lassoDraw();
2547
+ return;
2548
+ }
2549
+ if (editTool !== "smooth" && editTool !== "refine") return;
2550
+ ev.preventDefault();
2551
+ brushing = true;
2552
+ if (editTool === "smooth") {
2553
+ strokeTouched = new Map();
2554
+ brushAt(ev);
2555
+ } else {
2556
+ refineBegin(toUser(ev, svg));
2557
+ }
2558
+ });
2559
+ outStage.addEventListener("pointermove", function (ev) {
2560
+ if (lassoPts) {
2561
+ var svg2 = outSvg();
2562
+ if (svg2) { lassoPts.push(toUser(ev, svg2)); lassoDraw(); }
2563
+ return;
2564
+ }
2565
+ if (!brushing) return;
2566
+ if (editTool === "smooth") brushAt(ev);
2567
+ else { var svg = outSvg(); if (svg) refineExtend(toUser(ev, svg)); }
2568
+ });
2569
+ ["pointerup", "pointerleave", "pointercancel"].forEach(function (t) {
2570
+ outStage.addEventListener(t, function (ev) {
2571
+ if (lassoPts) { lassoAte = lassoEnd(ev) ? performance.now() : 0; return; }
2572
+ if (!brushing) return;
2573
+ if (editTool === "refine") { brushing = false; refineFinish(); }
2574
+ else endStroke();
2575
+ });
2576
+ });
2577
+
2578
+ $("editUndo").addEventListener("click", function () {
2579
+ var last = editStack.pop();
2580
+ if (!last) { setEditNote("Ei kumottavaa."); return; }
2581
+ if (last.kind === "fill") { last.el.setAttribute("fill", last.was); wandInvalidate(); }
2582
+ else if (last.kind === "d") { last.el.setAttribute("d", last.was); last.el.__rings = null; }
2583
+ else if (last.kind === "stroke") {
2584
+ last.was.forEach(function (w) {
2585
+ w.el.setAttribute("d", w.d);
2586
+ w.el.__rings = null;
2587
+ if (w.el.parentNode) return;
2588
+ // A shape the stroke erased, put back where it was drawn — appending it
2589
+ // to the end would restore the outline but not the stacking order.
2590
+ var parent = w.parent || outSvg();
2591
+ var next = w.next && w.next.parentNode === parent ? w.next : null;
2592
+ parent.insertBefore(w.el, next);
2593
+ });
2594
+ }
2595
+ else if (last.kind === "remove") { last.parent.insertBefore(last.el, last.next); last.el.__rings = null; }
2596
+ else if (last.kind === "isolate") {
2597
+ // Back to front: a removed shape's next sibling is either one that was
2598
+ // kept or one already put back, never one still missing.
2599
+ for (var r = last.removed.length - 1; r >= 0; r--) {
2600
+ var rec = last.removed[r];
2601
+ if (rec.intoMask) {
2602
+ rec.el.setAttribute("fill", rec.fill);
2603
+ rec.intoMask = false;
2604
+ }
2605
+ rec.parent.insertBefore(rec.el, rec.next);
2606
+ }
2607
+ if (last.masked) last.masked.forEach(function (el) { el.removeAttribute("mask"); });
2608
+ if (last.clipped) last.clipped.forEach(function (el) { el.removeAttribute("clip-path"); });
2609
+ if (last.clipEls) {
2610
+ last.clipEls.forEach(function (el) { if (el.parentNode) el.parentNode.removeChild(el); });
2611
+ }
2612
+ if (last.maskEl && last.maskEl.parentNode) last.maskEl.parentNode.removeChild(last.maskEl);
2613
+ if (last.vb) {
2614
+ var isv = outSvg();
2615
+ if (isv) {
2616
+ isv.setAttribute("viewBox", last.vb.viewBox);
2617
+ isv.setAttribute("width", last.vb.w);
2618
+ isv.setAttribute("height", last.vb.h);
2619
+ }
2620
+ }
2621
+ wandClear();
2622
+ wandInvalidate();
2623
+ }
2624
+ else {
2625
+ last.el.parentNode.removeChild(last.el);
2626
+ if (last.also && last.also.parentNode) last.also.parentNode.removeChild(last.also);
2627
+ }
2628
+ setEditNote("Kumottu.");
2629
+ });
2630
+
2631
+ $("editReset").addEventListener("click", function () {
2632
+ if (!lastSvg) return;
2633
+ outStage.innerHTML = lastSvg;
2634
+ editStack = [];
2635
+ wandSel = null;
2636
+ wandInvalidate();
2637
+ if (editOn) { explode(outSvg()); setEditNote("Palautettu jäljitettyyn tulokseen."); }
2638
+ });
2639
+
2640
+ dlBtn.addEventListener("click", function () {
2641
+ if (!lastSvg) return;
2642
+ // What is on screen is what gets saved — edits included.
2643
+ var live = outSvg();
2644
+ if (live) {
2645
+ live = live.cloneNode(true);
2646
+ Array.prototype.forEach.call(live.querySelectorAll(".wand-off"), function (el) {
2647
+ el.classList.remove("wand-off");
2648
+ if (!el.getAttribute("class")) el.removeAttribute("class");
2649
+ });
2650
+ }
2651
+ var text = live ? new XMLSerializer().serializeToString(live) : lastSvg;
2652
+ var blob = new Blob([text], { type: "image/svg+xml;charset=utf-8" });
2653
+ var a = document.createElement("a");
2654
+ a.href = URL.createObjectURL(blob);
2655
+ a.download = (sourceBitmap && sourceBitmap.name ? sourceBitmap.name.replace(/\.[^.]+$/, "") : "trace") + ".svg";
2656
+ a.click();
2657
+ URL.revokeObjectURL(a.href);
2658
+ });
2659
+
2660
+ function vectorize() {
2661
+ if (!sourceBitmap) return;
2662
+ if (busy) { pending = true; return; }
2663
+ busy = true;
2664
+ pending = false;
2665
+ runBtn.disabled = true;
2666
+ setStatus("Jäljitetään…");
2667
+
2668
+ // Yield so the status paints before the sync trace work.
2669
+ setTimeout(function () {
2670
+ try {
2671
+ var t0 = performance.now();
2672
+ var opts = EvgTraceOptions.defaults();
2673
+ opts.colorCount = +colorCount.value;
2674
+ opts.threshold = +threshold.value;
2675
+ opts.skipLuma = +skipLuma.value;
2676
+ opts.bgMode = bgMode.value;
2677
+ opts.minColorDelta = +minColorDelta.value;
2678
+ opts.minRegion = +minRegion.value;
2679
+ opts.absorbContrast = +absorbContrast.value;
2680
+ opts.paletteChroma = +paletteChroma.value;
2681
+ opts.paletteMute = +paletteMute.value;
2682
+ opts.paletteTint = +paletteTint.value;
2683
+ opts.paletteWarm = +paletteWarm.value;
2684
+ opts.paletteContrast = +paletteContrast.value;
2685
+ opts.smooth = +smooth.value;
2686
+ opts.layerMode = layerMode.value;
2687
+ opts.bgColor = bgColor.value;
2688
+ opts.bgTolerance = +bgTolerance.value;
2689
+ opts.contourMode = contourMode.value;
2690
+ opts.contourEdge = +contourEdge.value;
2691
+ opts.contourSpread = +contourSpread.value;
2692
+ opts.gradientFill = !!gradientFill.checked;
2693
+ opts.gradientGain = +gradientGain.value;
2694
+ opts.overlaySimilar = +overlaySimilar.value;
2695
+ opts.detailTrueColor = detailTrueColor.checked;
2696
+ opts.detailMinShare = +detailMinShare.value;
2697
+ opts.detailColorMerge = +detailColorMerge.value;
2698
+ opts.detailColorMax = +detailColorMax.value;
2699
+ opts.edgeMinRun = +edgeMinRun.value;
2700
+ opts.paletteMode = paletteMode.value;
2701
+ opts.paletteBias = paletteBias.value;
2702
+ opts.paletteHex = paletteMode.value === "auto" ? [] : userPalette.slice();
2703
+ opts.turdsize = +turdsize.value;
2704
+ opts.alphamax = +alphamax.value;
2705
+ opts.opttolerance = +opttolerance.value;
2706
+ opts.optcurve = !!optcurve.checked;
2707
+ opts.turnpolicy = turnpolicy.value;
2708
+ opts.blackOnWhite = !!blackOnWhite.checked;
2709
+ opts.fillHex = fillHex.value;
2710
+
2711
+ buildProcessed();
2712
+ var img = new ImageBuffer();
2713
+ img.init(sourceBitmap.w, sourceBitmap.h);
2714
+ var rgba = workingPixels();
2715
+ var n = sourceBitmap.w * sourceBitmap.h;
2716
+ for (var i = 0; i < n; i++) {
2717
+ var o = i * 4;
2718
+ var x = i % sourceBitmap.w;
2719
+ var y = (i / sourceBitmap.w) | 0;
2720
+ img.setPixelRGBA(x, y, rgba[o], rgba[o + 1], rgba[o + 2], rgba[o + 3]);
2721
+ }
2722
+
2723
+ var tr = EvgBitmapTracer.fromImageBuffer(img, opts);
2724
+ tr.trace();
2725
+ var svg = tr.toSVG();
2726
+ var ms = Math.round(performance.now() - t0);
2727
+ lastSvg = svg;
2728
+
2729
+ outStage.classList.remove("empty");
2730
+ // A new trace replaces the drawing edit mode was working on, so it also
2731
+ // ends the session rather than leaving a stale one pointing at nothing.
2732
+ if (editOn) leaveEdit();
2733
+ outStage.innerHTML = svg;
2734
+ dlBtn.disabled = !svg;
2735
+ editToggle.disabled = !svg;
2736
+
2737
+ var layers = tr.getLayers ? tr.getLayers() : [];
2738
+ var layerN = tr.layerCount ? tr.layerCount() : (layers ? layers.length : 0);
2739
+ paletteEl.innerHTML = "";
2740
+ lastPalette = [];
2741
+ if (layers && layers.length > 1) {
2742
+ paletteEl.hidden = false;
2743
+ // One swatch per color, not per layer. A layer is a shape, and the
2744
+ // overlay algorithm makes hundreds of them out of a handful of
2745
+ // colors — the strip was showing 407 squares for 18 colors, which
2746
+ // reads as a palette that has run away when it has not.
2747
+ var seenHex = {};
2748
+ for (var li = 0; li < layers.length; li++) {
2749
+ var hex = layers[li].fillHex || "#000000";
2750
+ if (seenHex[hex]) continue;
2751
+ seenHex[hex] = 1;
2752
+ lastPalette.push(hex);
2753
+ var sw = document.createElement("span");
2754
+ sw.className = "sw";
2755
+ sw.style.background = hex;
2756
+ sw.title = hex + " — klikkaa ottaaksesi tämän paletin muokattavaksi";
2757
+ sw.addEventListener("click", function () { adoptPalette(lastPalette); });
2758
+ paletteEl.appendChild(sw);
2759
+ }
2760
+ } else {
2761
+ paletteEl.hidden = true;
2762
+ }
2763
+
2764
+ var rings = tr.rings ? tr.rings.length : 0;
2765
+ // In "omat" mode the slider is irrelevant — the list is the count.
2766
+ var pmode = paletteMode.value;
2767
+ var asked = pmode === "fixed" ? userPalette.length : +colorCount.value;
2768
+ var collapsed = "";
2769
+ if (asked > 1 && layerN < asked) {
2770
+ collapsed = pmode === "auto"
2771
+ ? " (kuvassa ei ole " + asked + " erottuvaa sävyä)"
2772
+ : " (" + (asked - layerN) + "/" + asked + " sävyä jäi käyttämättä)";
2773
+ }
2774
+ var bgNote = "";
2775
+ if (asked > 1 && bgMode.value === "auto" &&
2776
+ tr.backgroundRemoved && !tr.backgroundRemoved()) {
2777
+ bgNote = " · tausta jäi (reuna ei yhtenäinen — kokeile Tausta: väri)";
2778
+ }
2779
+ // Everything up to the shape count is a fact about the trace and stays
2780
+ // put; the shape count and the byte count are facts about the drawing,
2781
+ // and edit mode changes the drawing. Keeping the two apart is what lets
2782
+ // the live half be refreshed without re-running anything.
2783
+ statusHead = "<b>OK</b> · " + sourceBitmap.w + "×" + sourceBitmap.h
2784
+ + " · " + ms + " ms · layers=" + layerN + collapsed + bgNote
2785
+ + (rings ? " · rings=" + rings : "");
2786
+ statusEl.innerHTML = statusHead + " · svg=" + svg.length + " B";
2787
+ statusEl.className = "good";
2788
+ } catch (e) {
2789
+ console.error(e);
2790
+ setStatus("Virhe: " + (e && e.message ? e.message : e), "bad");
2791
+ outStage.classList.add("empty");
2792
+ outStage.textContent = "—";
2793
+ dlBtn.disabled = true;
2794
+ editToggle.disabled = true;
2795
+ } finally {
2796
+ busy = false;
2797
+ runBtn.disabled = !sourceBitmap;
2798
+ if (pending) vectorize();
2799
+ }
2800
+ }, 16);
2801
+ }
2802
+ })();
2803
+ </script>
2804
+ </body>
2805
+ </html>