├── .github └── workflows │ └── push.yml ├── .gitignore ├── Cargo.toml ├── Dockerfile ├── LICENSE_APACHE ├── LICENSE_MIT ├── Makefile ├── README.md ├── config ├── build.rs ├── demo.png └── webpack.config.js ├── content ├── index.html └── styles │ ├── base.css │ ├── handheld.css │ └── screen.css ├── package.json └── src ├── art.rs ├── dom.rs ├── js ├── index.js └── wrapper.js ├── lib.rs └── utils.rs /.github/workflows/push.yml: -------------------------------------------------------------------------------- 1 | name: build-and-push 2 | on: 3 | push: 4 | branches: [ master ] 5 | jobs: 6 | build: 7 | runs-on: ubuntu-latest 8 | steps: 9 | - uses: actions/checkout@v4 10 | - uses: docker/login-action@v3 11 | with: 12 | username: ${{ secrets.DOCKER_USERNAME }} 13 | password: ${{ secrets.DOCKER_PASSWORD }} 14 | - uses: docker/build-push-action@v5 15 | with: 16 | context: . 17 | push: true 18 | tags: wafflespeanut/rusty-sketch:latest 19 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Legacy python (will be removed) 2 | *.pyc 3 | # Cargo 4 | target/ 5 | **/*.rs.bk 6 | Cargo.lock 7 | 8 | # wasm-pack 9 | pkg/ 10 | wasm-pack.log 11 | # JS output 12 | .build/ 13 | lib/ 14 | # Node stuff 15 | node_modules/ 16 | package-lock.json 17 | -------------------------------------------------------------------------------- /Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "rusty-sketch" 3 | version = "0.1.0" 4 | authors = ["Ravi Shankar "] 5 | edition = "2021" 6 | build = "config/build.rs" 7 | 8 | [lib] 9 | name = "charcoal" 10 | crate-type = ["cdylib", "rlib"] 11 | 12 | [features] 13 | default = ["console_error_panic_hook"] 14 | 15 | [dependencies] 16 | base64 = "0.22" 17 | js-sys = "0.3" 18 | wasm-bindgen = "0.2" 19 | # The `console_error_panic_hook` crate provides better debugging of panics by 20 | # logging them with `console.error`. This is great for development, but requires 21 | # all the `std::fmt` and `std::panicking` infrastructure, so isn't great for 22 | # code size when deploying. 23 | console_error_panic_hook = { version = "0.1", optional = true } 24 | 25 | [dependencies.getrandom] 26 | version = "0.2" 27 | default-features = false 28 | features = ["js"] 29 | 30 | [dependencies.image] 31 | version = "0.25" 32 | default-features = false 33 | features = [ 34 | "jpeg", 35 | "png" 36 | ] 37 | 38 | [dependencies.web-sys] 39 | version = "0.3" 40 | features = [ 41 | "Blob", 42 | "Document", 43 | "DomTokenList", 44 | "Element", 45 | "Event", 46 | "EventTarget", 47 | "File", 48 | "FileList", 49 | "FileReader", 50 | "HtmlElement", 51 | "HtmlImageElement", 52 | "HtmlInputElement", 53 | "HtmlPreElement", 54 | "Location", 55 | "Node", 56 | "NodeList", 57 | "UrlSearchParams", 58 | "Window", 59 | "XmlHttpRequest", 60 | "XmlHttpRequestEventTarget", 61 | "XmlHttpRequestResponseType", 62 | ] 63 | 64 | [profile.release] 65 | # Tell `rustc` to optimize for small code size. 66 | opt-level = "s" 67 | lto = true 68 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM wafflespeanut/rust-wasm-builder:nightly as rust 2 | COPY . /home/rust/src 3 | WORKDIR /home/rust/src 4 | RUN wasm-pack build 5 | 6 | FROM node as node 7 | COPY --from=rust /home/rust/src /home/node/app 8 | WORKDIR /home/node/app/pkg 9 | RUN npm link 10 | WORKDIR /home/node/app 11 | RUN npm link rusty-sketch && npm install && npm run build 12 | 13 | # These are all static assets. So, I'm shipping it with my static server. 14 | FROM wafflespeanut/static-server 15 | 16 | ENV SOURCE=/source 17 | ENV ADDRESS=0.0.0.0:8000 18 | 19 | COPY --from=node /home/node/app/.build /source 20 | COPY content/ /source/ 21 | RUN mv /source/styles /source/assets/styles 22 | 23 | ENTRYPOINT ["/server"] 24 | -------------------------------------------------------------------------------- /LICENSE_APACHE: -------------------------------------------------------------------------------- 1 | Apache License 2 | Version 2.0, January 2004 3 | http://www.apache.org/licenses/ 4 | 5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 6 | 7 | 1. Definitions. 8 | 9 | "License" shall mean the terms and conditions for use, reproduction, 10 | and distribution as defined by Sections 1 through 9 of this document. 11 | 12 | "Licensor" shall mean the copyright owner or entity authorized by 13 | the copyright owner that is granting the License. 14 | 15 | "Legal Entity" shall mean the union of the acting entity and all 16 | other entities that control, are controlled by, or are under common 17 | control with that entity. For the purposes of this definition, 18 | "control" means (i) the power, direct or indirect, to cause the 19 | direction or management of such entity, whether by contract or 20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 21 | outstanding shares, or (iii) beneficial ownership of such entity. 22 | 23 | "You" (or "Your") shall mean an individual or Legal Entity 24 | exercising permissions granted by this License. 25 | 26 | "Source" form shall mean the preferred form for making modifications, 27 | including but not limited to software source code, documentation 28 | source, and configuration files. 29 | 30 | "Object" form shall mean any form resulting from mechanical 31 | transformation or translation of a Source form, including but 32 | not limited to compiled object code, generated documentation, 33 | and conversions to other media types. 34 | 35 | "Work" shall mean the work of authorship, whether in Source or 36 | Object form, made available under the License, as indicated by a 37 | copyright notice that is included in or attached to the work 38 | (an example is provided in the Appendix below). 39 | 40 | "Derivative Works" shall mean any work, whether in Source or Object 41 | form, that is based on (or derived from) the Work and for which the 42 | editorial revisions, annotations, elaborations, or other modifications 43 | represent, as a whole, an original work of authorship. For the purposes 44 | of this License, Derivative Works shall not include works that remain 45 | separable from, or merely link (or bind by name) to the interfaces of, 46 | the Work and Derivative Works thereof. 47 | 48 | "Contribution" shall mean any work of authorship, including 49 | the original version of the Work and any modifications or additions 50 | to that Work or Derivative Works thereof, that is intentionally 51 | submitted to Licensor for inclusion in the Work by the copyright owner 52 | or by an individual or Legal Entity authorized to submit on behalf of 53 | the copyright owner. For the purposes of this definition, "submitted" 54 | means any form of electronic, verbal, or written communication sent 55 | to the Licensor or its representatives, including but not limited to 56 | communication on electronic mailing lists, source code control systems, 57 | and issue tracking systems that are managed by, or on behalf of, the 58 | Licensor for the purpose of discussing and improving the Work, but 59 | excluding communication that is conspicuously marked or otherwise 60 | designated in writing by the copyright owner as "Not a Contribution." 61 | 62 | "Contributor" shall mean Licensor and any individual or Legal Entity 63 | on behalf of whom a Contribution has been received by Licensor and 64 | subsequently incorporated within the Work. 65 | 66 | 2. Grant of Copyright License. Subject to the terms and conditions of 67 | this License, each Contributor hereby grants to You a perpetual, 68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 69 | copyright license to reproduce, prepare Derivative Works of, 70 | publicly display, publicly perform, sublicense, and distribute the 71 | Work and such Derivative Works in Source or Object form. 72 | 73 | 3. Grant of Patent License. Subject to the terms and conditions of 74 | this License, each Contributor hereby grants to You a perpetual, 75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 76 | (except as stated in this section) patent license to make, have made, 77 | use, offer to sell, sell, import, and otherwise transfer the Work, 78 | where such license applies only to those patent claims licensable 79 | by such Contributor that are necessarily infringed by their 80 | Contribution(s) alone or by combination of their Contribution(s) 81 | with the Work to which such Contribution(s) was submitted. If You 82 | institute patent litigation against any entity (including a 83 | cross-claim or counterclaim in a lawsuit) alleging that the Work 84 | or a Contribution incorporated within the Work constitutes direct 85 | or contributory patent infringement, then any patent licenses 86 | granted to You under this License for that Work shall terminate 87 | as of the date such litigation is filed. 88 | 89 | 4. Redistribution. You may reproduce and distribute copies of the 90 | Work or Derivative Works thereof in any medium, with or without 91 | modifications, and in Source or Object form, provided that You 92 | meet the following conditions: 93 | 94 | (a) You must give any other recipients of the Work or 95 | Derivative Works a copy of this License; and 96 | 97 | (b) You must cause any modified files to carry prominent notices 98 | stating that You changed the files; and 99 | 100 | (c) You must retain, in the Source form of any Derivative Works 101 | that You distribute, all copyright, patent, trademark, and 102 | attribution notices from the Source form of the Work, 103 | excluding those notices that do not pertain to any part of 104 | the Derivative Works; and 105 | 106 | (d) If the Work includes a "NOTICE" text file as part of its 107 | distribution, then any Derivative Works that You distribute must 108 | include a readable copy of the attribution notices contained 109 | within such NOTICE file, excluding those notices that do not 110 | pertain to any part of the Derivative Works, in at least one 111 | of the following places: within a NOTICE text file distributed 112 | as part of the Derivative Works; within the Source form or 113 | documentation, if provided along with the Derivative Works; or, 114 | within a display generated by the Derivative Works, if and 115 | wherever such third-party notices normally appear. The contents 116 | of the NOTICE file are for informational purposes only and 117 | do not modify the License. You may add Your own attribution 118 | notices within Derivative Works that You distribute, alongside 119 | or as an addendum to the NOTICE text from the Work, provided 120 | that such additional attribution notices cannot be construed 121 | as modifying the License. 122 | 123 | You may add Your own copyright statement to Your modifications and 124 | may provide additional or different license terms and conditions 125 | for use, reproduction, or distribution of Your modifications, or 126 | for any such Derivative Works as a whole, provided Your use, 127 | reproduction, and distribution of the Work otherwise complies with 128 | the conditions stated in this License. 129 | 130 | 5. Submission of Contributions. Unless You explicitly state otherwise, 131 | any Contribution intentionally submitted for inclusion in the Work 132 | by You to the Licensor shall be under the terms and conditions of 133 | this License, without any additional terms or conditions. 134 | Notwithstanding the above, nothing herein shall supersede or modify 135 | the terms of any separate license agreement you may have executed 136 | with Licensor regarding such Contributions. 137 | 138 | 6. Trademarks. This License does not grant permission to use the trade 139 | names, trademarks, service marks, or product names of the Licensor, 140 | except as required for reasonable and customary use in describing the 141 | origin of the Work and reproducing the content of the NOTICE file. 142 | 143 | 7. Disclaimer of Warranty. Unless required by applicable law or 144 | agreed to in writing, Licensor provides the Work (and each 145 | Contributor provides its Contributions) on an "AS IS" BASIS, 146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 147 | implied, including, without limitation, any warranties or conditions 148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 149 | PARTICULAR PURPOSE. You are solely responsible for determining the 150 | appropriateness of using or redistributing the Work and assume any 151 | risks associated with Your exercise of permissions under this License. 152 | 153 | 8. Limitation of Liability. In no event and under no legal theory, 154 | whether in tort (including negligence), contract, or otherwise, 155 | unless required by applicable law (such as deliberate and grossly 156 | negligent acts) or agreed to in writing, shall any Contributor be 157 | liable to You for damages, including any direct, indirect, special, 158 | incidental, or consequential damages of any character arising as a 159 | result of this License or out of the use or inability to use the 160 | Work (including but not limited to damages for loss of goodwill, 161 | work stoppage, computer failure or malfunction, or any and all 162 | other commercial damages or losses), even if such Contributor 163 | has been advised of the possibility of such damages. 164 | 165 | 9. Accepting Warranty or Additional Liability. While redistributing 166 | the Work or Derivative Works thereof, You may choose to offer, 167 | and charge a fee for, acceptance of support, warranty, indemnity, 168 | or other liability obligations and/or rights consistent with this 169 | License. However, in accepting such obligations, You may act only 170 | on Your own behalf and on Your sole responsibility, not on behalf 171 | of any other Contributor, and only if You agree to indemnify, 172 | defend, and hold each Contributor harmless for any liability 173 | incurred by, or claims asserted against, such Contributor by reason 174 | of your accepting any such warranty or additional liability. 175 | 176 | END OF TERMS AND CONDITIONS 177 | -------------------------------------------------------------------------------- /LICENSE_MIT: -------------------------------------------------------------------------------- 1 | Copyright (c) 2018 Ravi Shankar 2 | 3 | Permission is hereby granted, free of charge, to any 4 | person obtaining a copy of this software and associated 5 | documentation files (the "Software"), to deal in the 6 | Software without restriction, including without 7 | limitation the rights to use, copy, modify, merge, 8 | publish, distribute, sublicense, and/or sell copies of 9 | the Software, and to permit persons to whom the Software 10 | is furnished to do so, subject to the following 11 | conditions: 12 | 13 | The above copyright notice and this permission notice 14 | shall be included in all copies or substantial portions 15 | of the Software. 16 | 17 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF 18 | ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED 19 | TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A 20 | PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT 21 | SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY 22 | CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION 23 | OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR 24 | IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER 25 | DEALINGS IN THE SOFTWARE. 26 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | ENV = development 2 | 3 | ASSETS_DIR = assets 4 | BUILD_DIR = .build 5 | CONTENT_DIR = content 6 | WASM_OUT_DIR = pkg 7 | JS_OUT_DIR = lib 8 | STYLES_DIR = styles 9 | 10 | ASSETS_BUILD_DIR = $(BUILD_DIR)/$(ASSETS_DIR) 11 | JS_BUILD_DIR = $(ASSETS_BUILD_DIR)/scripts 12 | STYLES_OUT_DIR = $(BUILD_DIR)/$(STYLES_DIR) 13 | 14 | all: build run 15 | 16 | prepare: 17 | -cargo install wasm-pack 18 | -rustup component add rustfmt 19 | wasm-pack build 20 | cd $(WASM_OUT_DIR) && npm link && cd .. 21 | npm link rusty-sketch 22 | npm install 23 | 24 | clean: 25 | rm -rf pkg $(BUILD_DIR) lib 26 | 27 | build: clean 28 | mkdir -p $(ASSETS_BUILD_DIR) 29 | mkdir -p $(JS_BUILD_DIR) 30 | 31 | cp -rf $(CONTENT_DIR)/* $(BUILD_DIR)/ 32 | 33 | cargo fmt 34 | wasm-pack build 35 | NODE_ENV=$(ENV) npm run build 36 | 37 | # cp -r ./$(JS_OUT_DIR)/* $(JS_BUILD_DIR)/ 38 | mv $(STYLES_OUT_DIR) $(ASSETS_BUILD_DIR)/ 39 | 40 | run: build 41 | NODE_ENV=$(ENV) npm run start 42 | 43 | .PHONY: all prepare clean build 44 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ## ascii-art-generator 2 | 3 | > **NOTE:** This is a port of my [Python project](https://github.com/wafflespeanut/ascii-art-generator/tree/0b519b00b43eadb8500db30c304b2b87ad7eb159) to play with Rust and WASM. 4 | 5 | Generates ASCII arts from JPEG/PNG images. [Live demo](https://waffles.space/ascii-gen/). 6 | 7 | ### Usage 8 | 9 | ``` 10 | make prepare # install rustfmt, wasm-pack, npm deps, etc. 11 | make run 12 | ``` 13 | 14 | Then, visit `localhost:3000` in your browser. 15 | 16 | ### How it works? 17 | 18 | [I've blogged about it](https://blog.waffles.space/2017/03/01/ascii-sketch/). 19 | -------------------------------------------------------------------------------- /config/build.rs: -------------------------------------------------------------------------------- 1 | use std::env; 2 | use std::fs::File; 3 | use std::io::{Read, Write}; 4 | use std::path::Path; 5 | 6 | fn main() { 7 | let out_dir = env::var("OUT_DIR").unwrap(); 8 | let config_path = Path::new("config"); 9 | let builder = config_path.join("build.rs"); 10 | let demo = config_path.join("demo.png"); 11 | let out_path = Path::new(&out_dir).join("demo_output.rs"); 12 | 13 | // rebuild if any of these changed 14 | println!("cargo:rerun-if-changed={}", demo.display()); 15 | println!("cargo:rerun-if-changed={}", builder.display()); 16 | 17 | let mut bytes = vec![]; 18 | let mut fd = File::open(&demo).unwrap(); 19 | fd.read_to_end(&mut bytes).unwrap(); 20 | 21 | let contents = format!( 22 | " 23 | const DEMO_DATA: [u8; {}] = {:?}; 24 | ", 25 | bytes.len(), 26 | bytes 27 | ); 28 | 29 | let mut fd = File::create(&out_path).unwrap(); 30 | fd.write_all(contents.as_bytes()).unwrap(); 31 | } 32 | -------------------------------------------------------------------------------- /config/demo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wafflespeanut/ascii-art-generator/54cbdf1fd669b5e5d02b1c26bcf565cb0f0d8edc/config/demo.png -------------------------------------------------------------------------------- /config/webpack.config.js: -------------------------------------------------------------------------------- 1 | const path = require("path"); 2 | 3 | function resolve(dir) { 4 | return path.join(__dirname, '..', dir) 5 | } 6 | 7 | module.exports = { 8 | entry: { 9 | index: resolve("src/js/wrapper.js"), 10 | }, 11 | output: { 12 | path: resolve(".build/assets/scripts"), 13 | publicPath: "assets/scripts/" 14 | }, 15 | experiments: { 16 | asyncWebAssembly: true, 17 | syncWebAssembly: true, 18 | }, 19 | mode: "production" 20 | } 21 | -------------------------------------------------------------------------------- /content/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 7 | 8 | 9 | 10 | 11 | 12 | ASCII Art Generator 13 | 14 | 15 | 16 |
17 | 18 | 21 | 22 | 23 | 24 | 25 | Yay! It works! 26 | 27 |
28 |
29 | 30 | 31 | 32 |
33 |
34 |
Min level
35 | 36 | 37 |
38 |
39 |
Max level
40 | 41 | 42 |
43 |
44 |
Gamma
45 | 46 | 47 |
48 |
49 | 50 | 51 |
52 |
53 |
54 |

55 | 
56 | 
57 | 


--------------------------------------------------------------------------------
/content/styles/base.css:
--------------------------------------------------------------------------------
  1 | *, *:before, *:after {
  2 |   box-sizing: border-box;
  3 | }
  4 | 
  5 | * {
  6 |   margin: 0;
  7 |   padding: 0;
  8 | }
  9 | 
 10 | #rusty-box {
 11 |   display: grid;
 12 | }
 13 | 
 14 | #file-thingy {
 15 |   width: 0.1px;
 16 |   height: 0.1px;
 17 |   opacity: 0;
 18 |   overflow: hidden;
 19 |   position: absolute;
 20 |   z-index: -1;
 21 | }
 22 | 
 23 | label[for="file-thingy"] {
 24 |   cursor: pointer;
 25 | }
 26 | 
 27 | #art-box, #header-box div {
 28 |   text-align: center;
 29 | }
 30 | 
 31 | #art-box, #header-box {
 32 |   font-family: monospace, Courier;
 33 |   line-height: 1;
 34 | }
 35 | 
 36 | #progress-box {
 37 |   display: flex;
 38 |   justify-content: center;
 39 | }
 40 | 
 41 | .outline.remove {
 42 |   display: none;
 43 | }
 44 | 
 45 | .outline > #art-params {
 46 |   display: grid;
 47 |   opacity: 0;
 48 |   transition: opacity 1s;
 49 | }
 50 | 
 51 | .outline.show > #art-params {
 52 |   opacity: 1;
 53 | }
 54 | 
 55 | #art-params > div {
 56 |   text-align: center;
 57 | }
 58 | 
 59 | /* Slider inspired from https://codepen.io/seanstopnik/pen/CeLqA */
 60 | 
 61 | .range-slider > .range {
 62 |   -webkit-appearance: none;
 63 |   width: calc(100% - (100px));
 64 |   height: 10px;
 65 |   border-radius: 5px;
 66 |   background: #d7dcdf;
 67 |   outline: none;
 68 | }
 69 | 
 70 | .range-slider > .value {
 71 |   display: inline-block;
 72 |   position: relative;
 73 |   width: 60px;
 74 |   color: #fff;
 75 |   line-height: 20px;
 76 |   text-align: center;
 77 |   border-radius: 3px;
 78 |   background: #2c3e50;
 79 |   padding: 5px 10px;
 80 |   margin-left: 8px;
 81 | }
 82 | 
 83 | .range-slider > .value:after {
 84 |   content: '';
 85 |   position: absolute;
 86 |   top: 8px;
 87 |   left: -7px;
 88 |   width: 0;
 89 |   height: 0;
 90 |   border-top: 7px solid transparent;
 91 |   border-right: 7px solid #2c3e50;
 92 |   border-bottom: 7px solid transparent;
 93 | }
 94 | 
 95 | /* Transition stuff */
 96 | 
 97 | .divider {
 98 |   display: block;
 99 |   transition: all 0.5s;
100 |   background-color: black;
101 |   width: 1px;
102 |   height: 0;
103 |   position: relative;
104 | }
105 | 
106 | .divider.hline {
107 |   height: 1px;
108 |   transition-delay: 0.5s;
109 | }
110 | 
111 | .success-banner {
112 |   display: flex;
113 |   flex-flow: row;
114 |   align-items: center;
115 | }
116 | 
117 | .success-banner > .divider {
118 |   left: 0;
119 |   width: 60px;
120 | }
121 | 
122 | .success-banner > .divider > .hline {
123 |   width: 0;
124 | }
125 | 
126 | .success-banner.show > .divider > .hline {
127 |   width: inherit;
128 | }
129 | 
130 | .success-banner > .message {
131 |   margin-left: 10px;
132 |   opacity: 0;
133 |   transition: opacity 1s;
134 | }
135 | 
136 | .success-banner.show > .message {
137 |   opacity: 1;
138 | }
139 | 


--------------------------------------------------------------------------------
/content/styles/handheld.css:
--------------------------------------------------------------------------------
 1 | 
 2 | @import url("./base.css");
 3 | 
 4 | #rusty-box {
 5 |   padding-top: 10%;
 6 |   grid-template-rows: 1fr 1fr;
 7 | }
 8 | 
 9 | #header-box {
10 |   font-size: 2px;
11 | }
12 | 
13 | #art-box {
14 |   padding-top: 5%;
15 |   font-size: 2px;
16 | }
17 | 
18 | #progress-box {
19 |   flex-flow: column wrap;
20 |   align-self: center;
21 | }
22 | 
23 | #progress-box > img {
24 |   margin: 10px 25%;
25 |   width: 50%;
26 | }
27 | 
28 | .outline > #art-params {
29 |   flex-flow: column;
30 | }
31 | 
32 | .outline.show > .divider {
33 |   height: 20px;
34 | }
35 | 
36 | .range-slider {
37 |   width: 80%;
38 |   margin: 5px 10%;
39 | }
40 | 
41 | #art-params > div {
42 |   font-size: 12px;
43 | }
44 | 
45 | .divider {
46 |   left: 50%;
47 | }
48 | 
49 | .divider.hline {
50 |   left: 10%;
51 |   margin-bottom: 10px;
52 | }
53 | 
54 | .outline.show > .divider.hline {
55 |   height: 1px;
56 |   width: 80%;
57 | }
58 | 
59 | .success-banner {
60 |   justify-content: center;
61 |   font-size: 12px;
62 | }
63 | 
64 | .success-banner > .divider {
65 |   display: none;
66 | }
67 | 


--------------------------------------------------------------------------------
/content/styles/screen.css:
--------------------------------------------------------------------------------
 1 | 
 2 | #rusty-box {
 3 |   padding-top: 2%;
 4 |   grid-template-rows: 1fr;
 5 |   grid-template-columns: repeat(3, 1fr);
 6 | }
 7 | 
 8 | label[for="file-thingy"] {
 9 |   grid-column-start: 2;
10 | }
11 | 
12 | #header-box {
13 |   font-size: 4px;
14 | }
15 | 
16 | #art-box {
17 |   padding-top: 1%;
18 |   font-size: 4px;
19 | }
20 | 
21 | #progress-box {
22 |   flex-flow: row;
23 |   margin-top: 1%;
24 | }
25 | 
26 | #progress-box > img {
27 |   margin: 10px;
28 |   width: auto;
29 | }
30 | 
31 | .outline > #art-params {
32 |   grid-template-columns: repeat(5, 1fr);
33 |   margin-top: 10px;
34 | }
35 | 
36 | #art-params > div {
37 |   font-size: initial;
38 |   text-align: center;
39 | }
40 | 
41 | #art-params > div:nth-child(1) {
42 |   grid-column-start: 2;
43 | }
44 | 
45 | #art-params > div:last-child {
46 |   display: flex;
47 |   margin: 10px;
48 | }
49 | 
50 | .divider.hline {
51 |   margin: 0;
52 | }
53 | 
54 | .success-banner {
55 |   justify-content: left;
56 |   font-size: initial;
57 | }
58 | 
59 | .success-banner > .message {
60 |   transition: opacity 1s ease 1s;
61 | }
62 | 
63 | .success-banner > .divider {
64 |   display: inline-block;
65 | }
66 | 


--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
 1 | {
 2 |   "name": "ascii-art-generator",
 3 |   "version": "0.1.0",
 4 |   "description": "Entrypoint for rusty-sketch",
 5 |   "repository": {
 6 |     "url": "https://github.com/wafflespeanut/ascii-art-generator"
 7 |   },
 8 |   "license": "(MIT OR Apache-2.0)",
 9 |   "scripts": {
10 |     "build": "webpack --config config/webpack.config.js",
11 |     "start": "browser-sync .build -w"
12 |   },
13 |   "devDependencies": {
14 |     "rusty-sketch": "^0.1.0",
15 |     "webpack": "^5.91.0",
16 |     "webpack-cli": "^5.1.4",
17 |     "browser-sync": "^3.0.2"
18 |   }
19 | }
20 | 


--------------------------------------------------------------------------------
/src/art.rs:
--------------------------------------------------------------------------------
  1 | use crate::utils;
  2 | use image::{DynamicImage, GenericImageView, ImageError, RgbImage};
  3 | 
  4 | use std::cell::Cell;
  5 | use std::cmp;
  6 | use std::ops::Deref;
  7 | 
  8 | const BLEND_RATIO: f32 = 0.5;
  9 | const MAX_WIDTH: u32 = 500;
 10 | 
 11 | pub const DEFAULT_MIN_LEVEL: u8 = 78;
 12 | pub const DEFAULT_MAX_LEVEL: u8 = 125;
 13 | pub const DEFAULT_GAMMA: f32 = 0.78;
 14 | 
 15 | /* Constants below are obtained using Python. See https://github.com/wafflespeanut/ascii-art-generator/blob/0b519b00b43eadb8500db30c304b2b87ad7eb159/src/gen.py#L27-L39 */
 16 | 
 17 | // Char width and height based on system fonts.
 18 | const DEFAULT_CHAR_WIDTH: f32 = 6.0;
 19 | const DEFAULT_CHAR_HEIGHT: f32 = 11.0;
 20 | // Characters sorted based on the pixel density of their render.
 21 | const CHARS: &[char] = &[
 22 |     'H', '$', 'd', 'g', 'q', '0', 'p', 'R', '8', 'b', 'h', 'k', 'B', 'D', 'N', 'Q', 'U', '5', '6',
 23 |     '9', '@', 'A', 'K', 'y', 'E', 'G', 'O', 'Z', '2', '4', '#', 'a', 'f', 'u', 'M', 'P', 'S', '3',
 24 |     '%', 'l', 't', 'x', 'W', 'X', 'Y', '1', '&', 'j', 'n', 's', 'z', 'C', '7', 'e', 'i', 'm', 'o',
 25 |     'w', 'F', 'L', 'T', 'V', '[', ']', 'r', 'J', 'c', 'I', '{', '}', 'v', '(', ')', '?', '!', '<',
 26 |     '>', '*', '+', '/', '=', '\\', '^', '|', '"', ';', '_', '~', '-', '\'', ',', ':', '`', '.',
 27 |     ' ',
 28 | ];
 29 | 
 30 | /// This project - the whole deal.
 31 | pub struct AsciiArtGenerator {
 32 |     pub min_level: Cell,
 33 |     pub max_level: Cell,
 34 |     pub gamma: Cell,
 35 |     width: u32,
 36 |     height: u32,
 37 |     img: DynamicImage,
 38 |     ar: f32,
 39 | }
 40 | 
 41 | impl AsciiArtGenerator {
 42 |     /// Creates an instance from the given buffer.
 43 |     pub fn from_bytes(bytes: &[u8]) -> Result {
 44 |         let img = image::load_from_memory(bytes)?;
 45 |         let (w, h) = (img.width(), img.height());
 46 |         let clamped_width = cmp::min(w, MAX_WIDTH);
 47 | 
 48 |         let mut gen = AsciiArtGenerator {
 49 |             min_level: Cell::new(DEFAULT_MIN_LEVEL),
 50 |             max_level: Cell::new(DEFAULT_MAX_LEVEL),
 51 |             gamma: Cell::new(DEFAULT_GAMMA),
 52 | 
 53 |             img,
 54 |             width: w,
 55 |             height: h,
 56 |             ar: w as f32 / h as f32,
 57 |         };
 58 | 
 59 |         if clamped_width < w {
 60 |             gen.set_width(MAX_WIDTH);
 61 |         }
 62 | 
 63 |         Ok(gen)
 64 |     }
 65 | 
 66 |     /// Sets the width of the final image and returns the new height.
 67 |     ///
 68 |     /// **NOTE:**
 69 |     /// - No-op if the width is greater than the actual width of the image.
 70 |     /// - This also affects the height to maintain aspect ratio.
 71 |     /// - This only stores the dimensions - scaling is done while generating the art.
 72 |     /// - The image will be resized once again to match character widths and heights,
 73 |     /// but will be closer to this value.
 74 |     pub fn set_width(&mut self, width: u32) -> u32 {
 75 |         if width >= self.img.width() {
 76 |             return self.height;
 77 |         }
 78 | 
 79 |         self.width = width;
 80 |         self.height = (width as f32 / self.ar) as u32;
 81 |         self.height
 82 |     }
 83 | 
 84 |     /// Sets the height of the final image and returns the new width.
 85 |     ///
 86 |     /// **NOTE:**
 87 |     /// - No-op if the height is greater than the actual height of the image.
 88 |     /// - This also affects the width to maintain aspect ratio.
 89 |     /// - This only stores the dimensions - scaling is done while generating the art.
 90 |     /// - The height of the image will probably change later to fit the character
 91 |     /// widths and heights.
 92 |     pub fn set_height(&mut self, height: u32) -> u32 {
 93 |         let actual = self.img.height();
 94 |         if height >= actual {
 95 |             return self.width;
 96 |         }
 97 | 
 98 |         self.height = height;
 99 |         self.width = (height as f32 * self.ar) as u32;
100 |         self.width
101 |     }
102 | 
103 |     /// Return the processor which takes care of generating the artwork.
104 |     #[inline]
105 |     pub fn processor(&self) -> Processor<'_> {
106 |         Processor(self)
107 |     }
108 | }
109 | 
110 | /// The processor which actually generates the image. It constrains
111 | /// the generator from being modified.
112 | pub struct Processor<'a>(&'a AsciiArtGenerator);
113 | 
114 | impl<'a> Deref for Processor<'a> {
115 |     type Target = AsciiArtGenerator;
116 | 
117 |     fn deref(&self) -> &Self::Target {
118 |         self.0
119 |     }
120 | }
121 | 
122 | impl<'a> Processor<'a> {
123 |     /// Returns the resized image with corrections to the specified dimensions.
124 |     #[inline]
125 |     pub fn resize(&self) -> DynamicImage {
126 |         let h = (self.height as f32 * DEFAULT_CHAR_WIDTH / DEFAULT_CHAR_HEIGHT) as u32;
127 |         self.img
128 |             .resize_exact(self.width, h, image::imageops::Lanczos3)
129 |     }
130 | 
131 |     /// Applies Guassian blur and inverts the image. This will be blended
132 |     /// with the original image and adjusted for levels.
133 |     #[inline]
134 |     pub fn blur_and_invert(&self, img: &DynamicImage) -> DynamicImage {
135 |         let mut img = img.blur(8.0);
136 |         img.invert();
137 |         img
138 |     }
139 | 
140 |     /// Blend the given images and adjust levels.
141 |     pub fn blend_and_adjust(&self, actual: &DynamicImage, fg: &DynamicImage) -> DynamicImage {
142 |         let mut actual_buf = actual.to_rgb8();
143 |         let fg_buf = fg.to_rgb8();
144 |         self.blend_and_adjust_levels(&mut actual_buf, &fg_buf);
145 | 
146 |         let detailed = DynamicImage::ImageRgb8(actual_buf);
147 |         DynamicImage::ImageLuma8(detailed.to_luma8())
148 |     }
149 | 
150 |     /// Converts the image to Luma, maps the characters and returns a `String` iterator.
151 |     pub fn generate_from_img(&'a self, img: &'a DynamicImage) -> impl Iterator + 'a {
152 |         let multiplier = (CHARS.len() - 1) as f32;
153 |         let (width, height) = (img.width(), img.height());
154 |         (0..height).map(move |y| {
155 |             (0..width)
156 |                 .map(|x| {
157 |                     let p = img.get_pixel(x, y).0[0] as f32 / 255.0;
158 |                     CHARS[(p * multiplier + 0.5) as usize]
159 |                 })
160 |                 .collect()
161 |         })
162 |     }
163 | 
164 |     fn blend_and_adjust_levels(&self, actual_buf: &mut RgbImage, fg_buf: &RgbImage) {
165 |         let (min, max, inv_gamma) = (
166 |             self.min_level.get() as f32 / 255.0,
167 |             self.max_level.get() as f32 / 255.0,
168 |             1.0 / self.gamma.get(),
169 |         );
170 | 
171 |         actual_buf
172 |             .pixels_mut()
173 |             .zip(fg_buf.pixels())
174 |             .for_each(|(p1, p2)| {
175 |                 let r = blend_pixel(p1[0], p2[0], BLEND_RATIO);
176 |                 let g = blend_pixel(p1[1], p2[1], BLEND_RATIO);
177 |                 let b = blend_pixel(p1[2], p2[2], BLEND_RATIO);
178 | 
179 |                 let (h, s, mut v) = utils::convert_rgb_to_hsv((r, g, b));
180 |                 if v <= min {
181 |                     v = 0.0;
182 |                 } else if v >= max {
183 |                     v = 1.0;
184 |                 } else {
185 |                     v = ((v - min) / (max - min)).powf(inv_gamma);
186 |                 }
187 | 
188 |                 let (r, g, b) = utils::convert_hsv_to_rgb((h, s, v));
189 |                 p1.0 = [
190 |                     (r * 255.0).round() as u8,
191 |                     (g * 255.0).round() as u8,
192 |                     (b * 255.0).round() as u8,
193 |                 ];
194 |             });
195 |     }
196 | }
197 | 
198 | /// Blends a pixel value using the given ratio and returns the normalized value in [0, 1]
199 | #[inline]
200 | const fn blend_pixel(p1: u8, p2: u8, ratio: f32) -> f32 {
201 |     (p1 as f32 * (1.0 - ratio) + p2 as f32 * ratio) / 255.0
202 | }
203 | 


--------------------------------------------------------------------------------
/src/dom.rs:
--------------------------------------------------------------------------------
  1 | use crate::art::AsciiArtGenerator;
  2 | 
  3 | use base64::prelude::*;
  4 | use image::DynamicImage;
  5 | use js_sys::Uint8Array;
  6 | use wasm_bindgen::prelude::*;
  7 | 
  8 | use std::cell::{Cell, RefCell};
  9 | use std::cmp;
 10 | use std::io::{BufWriter, Cursor};
 11 | use std::rc::Rc;
 12 | 
 13 | const THUMB_HEIGHT: u32 = 50;
 14 | 
 15 | /// A thing for reading files and injecting the art.
 16 | pub struct DomAsciiArtInjector {
 17 |     pub window: Rc,
 18 |     pub document: Rc,
 19 |     pub keeper: Rc>,
 20 | }
 21 | 
 22 | impl DomAsciiArtInjector {
 23 |     /// Initialize this injector with the IDs of `
` element (for injecting art)
 24 |     /// and `` element for subscribing to file loads.
 25 |     pub fn init() -> Self {
 26 |         let window = web_sys::window().map(Rc::new).expect("getting window");
 27 |         let document = window.document().map(Rc::new).expect("getting document");
 28 | 
 29 |         DomAsciiArtInjector {
 30 |             window,
 31 |             document,
 32 |             keeper: TimingEventKeeper::new(),
 33 |         }
 34 |     }
 35 | 
 36 |     /// Inject into the `
` element matching the given ID using the given image data.
 37 |     pub fn inject_from_data(&self, pre_elem_id: &str, buffer: &[u8]) -> Result<(), JsValue> {
 38 |         let pre = get_elem_by_id!(self.document > pre_elem_id => web_sys::HtmlPreElement)?;
 39 |         let gen = AsciiArtGenerator::from_bytes(buffer)
 40 |             .map(Rc::new)
 41 |             .expect("failed to load demo.");
 42 |         Self::inject_from_data_using_document(
 43 |             gen,
 44 |             &self.document,
 45 |             &self.keeper,
 46 |             &pre,
 47 |             0,
 48 |             |_| Ok(()),
 49 |             |draw| {
 50 |                 console_log!("Yay!");
 51 |                 draw();
 52 |                 Ok(())
 53 |             },
 54 |         );
 55 |         Ok(())
 56 |     }
 57 | 
 58 |     /// Downloads image from the given URL and updates the `
` element.
 59 |     pub fn inject_from_url(
 60 |         &self,
 61 |         url: &str,
 62 |         pre_elem_id: &str,
 63 |         min: Option,
 64 |         max: Option,
 65 |         gamma: Option,
 66 |         width: Option,
 67 |         timeout_ms: u32,
 68 |         final_callback: F,
 69 |     ) -> Result<(), JsValue>
 70 |     where
 71 |         F: Fn(Box) -> Result<(), JsValue> + Clone + 'static,
 72 |     {
 73 |         let pre = get_elem_by_id!(self.document > pre_elem_id => web_sys::HtmlPreElement)?;
 74 | 
 75 |         let xhr = web_sys::XmlHttpRequest::new().map(Rc::new)?;
 76 |         xhr.open("GET", url)?;
 77 |         xhr.set_response_type(web_sys::XmlHttpRequestResponseType::Arraybuffer);
 78 | 
 79 |         let (x, d, k) = (xhr.clone(), self.document.clone(), self.keeper.clone());
 80 |         let download = Closure::wrap(Box::new(move |_: web_sys::Event| {
 81 |             if x.ready_state() != web_sys::XmlHttpRequest::DONE {
 82 |                 console_log!("Ajax not ready yet.");
 83 |                 return;
 84 |             }
 85 | 
 86 |             let status = x.status().expect("getting status");
 87 |             if status != 200 {
 88 |                 console_log!("Error fetching image. Got {} status code.", status);
 89 |                 return;
 90 |             }
 91 | 
 92 |             let value = x.response().expect("loading complete but no result?");
 93 |             let buffer = Uint8Array::new(&value);
 94 |             let mut bytes = vec![0; buffer.length() as usize];
 95 |             buffer.copy_to(&mut bytes);
 96 |             let gen = AsciiArtGenerator::from_bytes(&bytes)
 97 |                 .map(|mut gen| {
 98 |                     if let Some(w) = width {
 99 |                         gen.set_width(w);
100 |                     }
101 | 
102 |                     gen
103 |                 })
104 |                 .map(Rc::new)
105 |                 .expect("failed to load image.");
106 | 
107 |             if let Some(m) = min {
108 |                 gen.min_level.set(m);
109 |             }
110 | 
111 |             if let Some(m) = max {
112 |                 gen.max_level.set(m);
113 |             }
114 | 
115 |             if let Some(m) = gamma {
116 |                 gen.gamma.set(m);
117 |             }
118 | 
119 |             console_log!("Loaded {} bytes", bytes.len());
120 |             Self::inject_from_data_using_document(
121 |                 gen,
122 |                 &d,
123 |                 &k,
124 |                 &pre,
125 |                 timeout_ms,
126 |                 |_| -> Result<(), JsValue> { Ok(()) },
127 |                 final_callback.clone(),
128 |             );
129 |         }) as Box);
130 | 
131 |         xhr.set_onload(Some(download.as_ref().unchecked_ref()));
132 |         download.forget();
133 |         xhr.send()?;
134 | 
135 |         Ok(())
136 |     }
137 | 
138 |     /// Adds an event listener to watch and update the `
` element
139 |     /// whenever a file is loaded.
140 |     pub fn inject_on_file_loads(
141 |         &self,
142 |         input_elem_id: &str,
143 |         pre_elem_id: &str,
144 |         progress_elem_id: &str,
145 |         timeout_ms: u32,
146 |         final_callback: F,
147 |     ) -> Result<(), JsValue>
148 |     where
149 |         F: Fn(Box) -> Result<(), JsValue> + Clone + 'static,
150 |     {
151 |         // Setup the stage.
152 |         let reader = web_sys::FileReader::new().map(Rc::new)?;
153 |         let pre = get_elem_by_id!(self.document > pre_elem_id => web_sys::HtmlPreElement)?;
154 |         let prog = get_elem_by_id!(self.document > progress_elem_id => web_sys::Element)?;
155 |         let input = get_elem_by_id!(self.document > input_elem_id => web_sys::HtmlInputElement)?;
156 |         input.set_value(""); // reset input element
157 | 
158 |         let min_inp =
159 |             query_selector!(self.document > "#min-level > .range" => web_sys::HtmlInputElement)?;
160 |         let max_inp =
161 |             query_selector!(self.document > "#max-level > .range" => web_sys::HtmlInputElement)?;
162 |         let gamma_inp =
163 |             query_selector!(self.document > "#gamma > .range" => web_sys::HtmlInputElement)?;
164 | 
165 |         {
166 |             let (r, k, doc) = (reader.clone(), self.keeper.clone(), self.document.clone());
167 |             let closure = Closure::wrap(Box::new(move |_: web_sys::Event| {
168 |                 // Something has changed. Reset progress and get new values and buffer.
169 |                 prog.set_inner_html("");
170 |                 let (min, max, gamma) = (
171 |                     min_inp.value_as_number() as u8,
172 |                     max_inp.value_as_number() as u8,
173 |                     gamma_inp.value_as_number() as f32,
174 |                 );
175 | 
176 |                 let value = r.result().expect("reading complete but no result?");
177 |                 let buffer = Uint8Array::new(&value);
178 |                 let mut bytes = vec![0; buffer.length() as usize];
179 |                 buffer.copy_to(&mut bytes);
180 |                 let gen = AsciiArtGenerator::from_bytes(&bytes)
181 |                     .map(Rc::new)
182 |                     .expect("failed to load image.");
183 |                 gen.min_level.set(min);
184 |                 gen.max_level.set(max);
185 |                 gen.gamma.set(gamma);
186 | 
187 |                 console_log!("Loaded {} bytes", bytes.len());
188 |                 let (doc, prog) = (doc.clone(), prog.clone());
189 |                 Self::inject_from_data_using_document(
190 |                     gen,
191 |                     &doc.clone(),
192 |                     &k,
193 |                     &pre,
194 |                     timeout_ms,
195 |                     move |img: &DynamicImage| -> Result<(), JsValue> {
196 |                         // Whenever we get an image, resize it to a thumbnail.
197 |                         let new_h = cmp::min(img.height(), THUMB_HEIGHT);
198 |                         let new_w =
199 |                             (new_h as f32 * img.width() as f32 / img.height() as f32) as u32;
200 |                         let img = img.resize_exact(new_w, new_h, image::imageops::Lanczos3);
201 |                         let bytes = Cursor::new(vec![]);
202 |                         let mut writer = BufWriter::new(bytes);
203 |                         img.write_to(&mut writer, image::ImageFormat::Jpeg)
204 |                             .expect("invalid image?");
205 | 
206 |                         // Encode the image to base64 and append it to the document for preview.
207 |                         let b64 = BASE64_STANDARD.encode(
208 |                             writer
209 |                                 .into_inner()
210 |                                 .expect("getting bytes from writer")
211 |                                 .into_inner(),
212 |                         );
213 |                         let img = doc
214 |                             .create_element("img")?
215 |                             .dyn_into::()?;
216 |                         img.set_src(&format!("data:image/jpeg;base64,{}", b64));
217 |                         prog.append_child(&img)?;
218 | 
219 |                         Ok(())
220 |                     },
221 |                     final_callback.clone(),
222 |                 );
223 |             }) as Box);
224 | 
225 |             reader.set_onload(Some(closure.as_ref().unchecked_ref()));
226 |             closure.forget();
227 |         }
228 | 
229 |         self.add_file_listener(input, reader)
230 |     }
231 | 
232 |     /// Adds event listener for reading files.
233 |     fn add_file_listener(
234 |         &self,
235 |         input: Rc,
236 |         reader: Rc,
237 |     ) -> Result<(), JsValue> {
238 |         let inp = input.clone();
239 |         let closure = Closure::wrap(Box::new(move |_: web_sys::Event| {
240 |             console_log!("change event");
241 |             let file = match inp
242 |                 .files()
243 |                 .and_then(|l| l.get(l.length().saturating_sub(1)))
244 |             {
245 |                 Some(f) => f.slice().expect("failed to get blob"),
246 |                 None => return,
247 |             };
248 | 
249 |             reader
250 |                 .read_as_array_buffer(&file)
251 |                 .expect("failed to read file");
252 |         }) as Box);
253 | 
254 |         input.set_onchange(Some(closure.as_ref().unchecked_ref()));
255 |         closure.forget();
256 |         Ok(())
257 |     }
258 | 
259 |     /// Gets image data from buffer, generates ASCII art and injects into `
` element.
260 |     /// Each step produces an image, steps can be spaced by timeouts, and a callback is
261 |     /// called after each step. Also takes a final callback for invoking the final draw.
262 |     // NOTE: Yes, this is unnecessarily complicated, I know!
263 |     fn inject_from_data_using_document(
264 |         gen: Rc,
265 |         doc: &Rc,
266 |         keeper: &Rc>,
267 |         pre: &Rc,
268 |         step_timeout_ms: u32,
269 |         callback: F,
270 |         final_callback: U,
271 |     ) where
272 |         F: Fn(&DynamicImage) -> Result<(), JsValue> + 'static,
273 |         U: FnOnce(Box) -> Result<(), JsValue> + 'static,
274 |     {
275 |         pre.set_inner_html(""); // reset 
 element
276 |         let delay = Rc::new(Cell::new(step_timeout_ms));
277 | 
278 |         // Callback hell begins!
279 |         let (pre, doc, inner_d, inner_k) =
280 |             (pre.clone(), doc.clone(), delay.clone(), keeper.clone());
281 |         let f = move || {
282 |             let proc = gen.processor();
283 |             let img = proc.resize();
284 |             callback(&img).expect("queueing resized image");
285 | 
286 |             let (outer_d, outer_k) = (inner_d.clone(), inner_k.clone());
287 |             let f = move || {
288 |                 let proc = gen.processor();
289 |                 let fg = proc.blur_and_invert(&img);
290 |                 callback(&fg).expect("queueing blending image");
291 | 
292 |                 let (outer_d, outer_k) = (inner_d.clone(), inner_k.clone());
293 |                 let f = move || {
294 |                     let proc = gen.processor();
295 |                     let final_img = proc.blend_and_adjust(&img, &fg);
296 |                     callback(&final_img).expect("queueing final image");
297 | 
298 |                     let (outer_d, outer_k) = (inner_d.clone(), inner_k.clone());
299 |                     let f = move || {
300 |                         let draw = Box::new(move || {
301 |                             // Move the timeout keeper inside to prevent clearing all timeouts.
302 |                             let _keeper = inner_k.clone();
303 |                             let proc = gen.processor();
304 |                             for text in proc.generate_from_img(&final_img) {
305 |                                 let div = doc
306 |                                     .create_element("div")
307 |                                     .expect("creating art element")
308 |                                     .dyn_into::()
309 |                                     .expect("casting created element");
310 |                                 div.set_inner_text(&text);
311 |                                 pre.append_child(&div).expect("appending div");
312 |                             }
313 |                         }) as Box<_>;
314 | 
315 |                         final_callback(draw).expect("final callback")
316 |                     };
317 | 
318 |                     outer_k
319 |                         .borrow_mut()
320 |                         .add(f, outer_d.update(|x| x + step_timeout_ms));
321 |                 };
322 | 
323 |                 outer_k
324 |                     .borrow_mut()
325 |                     .add(f, outer_d.update(|x| x + step_timeout_ms));
326 |             };
327 | 
328 |             outer_k
329 |                 .borrow_mut()
330 |                 .add(f, outer_d.update(|x| x + step_timeout_ms));
331 |         };
332 | 
333 |         keeper.borrow_mut().add(f, delay.get());
334 |     }
335 | }
336 | 
337 | /// Abstraction for keeping track of timeouts. This takes `FnOnce` thingies for
338 | /// registering the timeouts (`FnMut` thingies for intervals) and clears them when
339 | /// it goes out of scope (also dropping the closures).
340 | pub struct TimingEventKeeper {
341 |     stuff: Vec<(i32, Closure, bool)>,
342 | }
343 | 
344 | impl TimingEventKeeper {
345 |     pub fn new() -> Rc> {
346 |         Rc::new(RefCell::new(TimingEventKeeper { stuff: vec![] }))
347 |     }
348 | 
349 |     /// Adds an `FnOnce` closure with a timeout.
350 |     pub fn add(&mut self, f: F, timeout_ms: u32)
351 |     where
352 |         F: FnOnce() + 'static,
353 |     {
354 |         let f = Closure::once(Box::new(f) as Box);
355 |         let id = crate::set_timeout_simple(&f, timeout_ms as i32);
356 |         self.stuff.push((id, f, false));
357 |     }
358 | 
359 |     /// Adds an `FnMut` closure with an interval for repetitive callback.
360 |     pub fn add_repetitive(&mut self, f: F, interval_ms: u32)
361 |     where
362 |         F: FnMut() + 'static,
363 |     {
364 |         let f = Closure::wrap(Box::new(f) as Box);
365 |         let id = crate::set_interval_simple(&f, interval_ms as i32);
366 |         self.stuff.push((id, f, true))
367 |     }
368 | }
369 | 
370 | impl Drop for TimingEventKeeper {
371 |     fn drop(&mut self) {
372 |         self.stuff.drain(..).for_each(|(id, _, repeating)| {
373 |             if repeating {
374 |                 crate::clear_interval(id);
375 |             } else {
376 |                 crate::clear_timeout(id);
377 |             }
378 |         });
379 |     }
380 | }
381 | 


--------------------------------------------------------------------------------
/src/js/index.js:
--------------------------------------------------------------------------------
1 | import * as sketch from "rusty-sketch";
2 | 
3 | sketch.start();
4 | 


--------------------------------------------------------------------------------
/src/js/wrapper.js:
--------------------------------------------------------------------------------
1 | import("./index.js")
2 |   .catch(e => console.error("Error importing `index.js`:", e));
3 | 


--------------------------------------------------------------------------------
/src/lib.rs:
--------------------------------------------------------------------------------
  1 | #![feature(cell_update, const_fn_floating_point_arithmetic)]
  2 | 
  3 | use wasm_bindgen::prelude::*;
  4 | 
  5 | use std::cell::Cell;
  6 | use std::rc::Rc;
  7 | 
  8 | /// `println!`-like macro for JS `console.log`
  9 | macro_rules! console_log {
 10 |     ($($arg:tt)*) => (crate::js_log_simple(&std::fmt::format(format_args!($($arg)*))))
 11 | }
 12 | 
 13 | /// `document.getElementById`
 14 | macro_rules! get_elem_by_id {
 15 |     ($($foo:ident).* > $id:expr => $ty:ty) => {
 16 |         $($foo).*.get_element_by_id($id)
 17 |             .expect(&format!("cannot find {}", $id))
 18 |             .dyn_into::<$ty>()
 19 |             .map(std::rc::Rc::new)
 20 |     };
 21 | }
 22 | 
 23 | /// `document.querySelector`
 24 | macro_rules! query_selector {
 25 |     ($($foo:ident).* > $rule:expr => $ty:ty) => {
 26 |         $($foo).*.query_selector($rule)?
 27 |             .expect(&format!("no items match {}", $rule))
 28 |             .dyn_into::<$ty>()
 29 |             .map(std::rc::Rc::new)
 30 |     };
 31 | }
 32 | 
 33 | mod art;
 34 | mod dom;
 35 | mod utils;
 36 | include!(concat!(env!("OUT_DIR"), "/demo_output.rs"));
 37 | 
 38 | pub use self::art::AsciiArtGenerator;
 39 | pub use self::dom::{DomAsciiArtInjector, TimingEventKeeper};
 40 | 
 41 | use self::art::{DEFAULT_GAMMA, DEFAULT_MAX_LEVEL, DEFAULT_MIN_LEVEL};
 42 | 
 43 | #[wasm_bindgen]
 44 | pub fn start() -> Result<(), JsValue> {
 45 |     utils::set_panic_hook();
 46 |     let injector = DomAsciiArtInjector::init();
 47 |     let search_str = injector.window.location().search()?;
 48 |     let params = web_sys::UrlSearchParams::new_with_str(&search_str)?;
 49 |     let content = query_selector!(injector.document > ".outline" => web_sys::Element)?;
 50 | 
 51 |     if let Some(url) = params.get("url") {
 52 |         content.class_list().add_1("remove")?;
 53 | 
 54 |         return injector.inject_from_url(
 55 |             &url,
 56 |             "art-box",
 57 |             params.get("min").and_then(|v| v.parse().ok()),
 58 |             params.get("max").and_then(|v| v.parse().ok()),
 59 |             params.get("gamma").and_then(|v| v.parse().ok()),
 60 |             params.get("width").and_then(|v| v.parse().ok()),
 61 |             50,
 62 |             |draw: Box| {
 63 |                 draw();
 64 | 
 65 |                 Ok(())
 66 |             },
 67 |         );
 68 |     }
 69 | 
 70 |     injector.inject_from_data("header-box", &DEMO_DATA)?;
 71 |     display_success(&injector.document)?;
 72 | 
 73 |     let (k, o) = (injector.keeper.clone(), content.clone());
 74 |     // Currently, image resizing takes an awful lot of time for huge images.
 75 |     // `image` doesn't use SIMD, and we can't use rayon in wasm.
 76 |     injector.inject_on_file_loads(
 77 |         "file-thingy",  // input element
 78 |         "art-box",      // art 
 element
 79 |         "progress-box", // progress element
 80 |         50,             // step timeout
 81 |         move |draw: Box| {
 82 |             let list = o.class_list();
 83 |             // If we've already shown the contents, then we're done.
 84 |             if list.contains("show") {
 85 |                 return Ok(draw());
 86 |             }
 87 | 
 88 |             list.add_1("show")?;
 89 |             let inner_k = k.clone();
 90 |             k.borrow_mut().add(
 91 |                 move || {
 92 |                     let _ = inner_k; // move keeper to avoid cancelling timeouts.
 93 |                     draw();
 94 |                 },
 95 |                 1000,
 96 |             );
 97 | 
 98 |             Ok(())
 99 |         },
100 |     )?;
101 | 
102 |     set_listeners(&injector.document, content)
103 | }
104 | 
105 | // FIXME: Need to clean this up!
106 | 
107 | fn set_listeners(
108 |     doc: &Rc,
109 |     content: Rc,
110 | ) -> Result<(), JsValue> {
111 |     // Add listeners to change value whenever the range input is changed.
112 |     let inputs = doc.query_selector_all("#art-params > .range-slider > .range")?;
113 |     (0..inputs.length())
114 |         .filter_map(|i| inputs.get(i))
115 |         .try_for_each(|node| -> Result<(), JsValue> {
116 |             let input = node.dyn_into::().map(Rc::new)?;
117 |             // Whenever a slider is changed, we need to update the relevant spans.
118 |             let i = input.clone();
119 |             let f = move || {
120 |                 let value = i.value();
121 |                 let n = i
122 |                     .next_sibling()
123 |                     .and_then(|n| n.next_sibling())
124 |                     .expect("no slider value?")
125 |                     .dyn_into::()
126 |                     .expect("casting span?");
127 |                 n.set_text_content(Some(&value));
128 |             };
129 | 
130 |             let wrapped =
131 |                 Closure::wrap(Box::new(move |_: web_sys::Event| f()) as Box);
132 |             input.set_oninput(Some(wrapped.as_ref().unchecked_ref()));
133 |             wrapped.forget();
134 |             Ok(())
135 |         })?;
136 | 
137 |     let (min, max, gamma) = (
138 |         Rc::new(Cell::new(0)),
139 |         Rc::new(Cell::new(0)),
140 |         Rc::new(Cell::new(0.0)),
141 |     );
142 | 
143 |     let f_inp = get_elem_by_id!(doc > "file-thingy" => web_sys::HtmlInputElement)?;
144 |     let reset_button = query_selector!(doc > "#art-params #reset" => web_sys::EventTarget)?;
145 |     let min_inp = query_selector!(doc > "#min-level > .range" => web_sys::HtmlInputElement)?;
146 |     let max_inp = query_selector!(doc > "#max-level > .range" => web_sys::HtmlInputElement)?;
147 |     let gamma_inp = query_selector!(doc > "#gamma > .range" => web_sys::HtmlInputElement)?;
148 | 
149 |     let (mi_in, mx_in, g_in) = (min_inp.clone(), max_inp.clone(), gamma_inp.clone());
150 |     let reset = move || {
151 |         for &(e, v) in &[
152 |             (&mi_in, DEFAULT_MIN_LEVEL as f64),
153 |             (&mx_in, DEFAULT_MAX_LEVEL as f64),
154 |             (&g_in, DEFAULT_GAMMA as f64),
155 |         ] {
156 |             e.set_value_as_number(v);
157 |             let ev = web_sys::Event::new("input").expect("creating reset event");
158 |             e.dispatch_event(&ev).expect("dispatching reset event");
159 |         }
160 |     };
161 | 
162 |     reset(); // initial slider reset to defaults
163 | 
164 |     let change_button = query_selector!(doc > "#art-params #change" => web_sys::EventTarget)?;
165 | 
166 |     let emit = move || {
167 |         let (mi, mx, g) = (
168 |             min_inp.value_as_number() as u8,
169 |             max_inp.value_as_number() as u8,
170 |             gamma_inp.value_as_number() as f32,
171 |         );
172 | 
173 |         let mut changed = false; // check if any parameter has changed and is valid.
174 |         changed |= mi != min.get() && (0..=255).contains(&mi);
175 |         changed |= mx != max.get() && (0..=255).contains(&mx);
176 |         changed |= g != gamma.get() && (0.0..=1.0).contains(&g);
177 | 
178 |         if changed {
179 |             // If something's changed, emit a change event at the input.
180 |             min.set(mi);
181 |             max.set(mx);
182 |             gamma.set(g);
183 | 
184 |             let list = content.class_list();
185 |             // If we've already shown the contents, then hide it.
186 |             if list.contains("show") {
187 |                 list.remove_1("show").expect("removing class?");
188 |             }
189 | 
190 |             let event = web_sys::Event::new("change").expect("creating event");
191 |             f_inp.dispatch_event(&event).expect("dispatching event");
192 |         } else {
193 |             console_log!("Nothing to do.");
194 |         }
195 |     };
196 | 
197 |     emit(); // initial sync of slider spans with slider values.
198 | 
199 |     let e = emit.clone();
200 |     let f = Closure::wrap(Box::new(move |_: web_sys::Event| {
201 |         reset();
202 |         e(); // also emit during reset.
203 |     }) as Box);
204 |     reset_button.add_event_listener_with_callback("click", f.as_ref().unchecked_ref())?;
205 |     f.forget();
206 | 
207 |     let f = Closure::wrap(Box::new(move |_: web_sys::Event| emit()) as Box);
208 |     change_button.add_event_listener_with_callback("click", f.as_ref().unchecked_ref())?;
209 |     f.forget();
210 | 
211 |     Ok(())
212 | }
213 | 
214 | fn display_success(doc: &web_sys::Document) -> Result<(), JsValue> {
215 |     let banner = query_selector!(doc > ".success-banner" => web_sys::Element)?;
216 |     let list = banner.class_list();
217 |     list.add_1("show")
218 | }
219 | 
220 | /* FFI */
221 | 
222 | #[wasm_bindgen]
223 | extern "C" {
224 |     #[wasm_bindgen(js_name = setTimeout)]
225 |     fn set_timeout_simple(closure: &Closure, timeout_ms: i32) -> i32;
226 | 
227 |     #[wasm_bindgen(js_name = clearTimeout)]
228 |     fn clear_timeout(id: i32);
229 | 
230 |     #[wasm_bindgen(js_name = setInterval)]
231 |     fn set_interval_simple(closure: &Closure, interval_ms: i32) -> i32;
232 | 
233 |     #[wasm_bindgen(js_name = clearInterval)]
234 |     fn clear_interval(id: i32);
235 | 
236 |     #[wasm_bindgen(js_namespace = console, js_name = log)]
237 |     fn js_log_simple(s: &str);
238 | }
239 | 


--------------------------------------------------------------------------------
/src/utils.rs:
--------------------------------------------------------------------------------
 1 | /// Sets panic hook for debugging.
 2 | ///
 3 | /// Available only when `console_error_panic_hook` feature is enabled.
 4 | pub(crate) fn set_panic_hook() {
 5 |     // When the `console_error_panic_hook` feature is enabled, we can call the
 6 |     // `set_panic_hook` function at least once during initialization, and then
 7 |     // we will get better error messages if our code ever panics.
 8 |     //
 9 |     // For more details see
10 |     // https://github.com/rustwasm/console_error_panic_hook#readme
11 |     #[cfg(feature = "console_error_panic_hook")]
12 |     console_error_panic_hook::set_once();
13 | }
14 | 
15 | /* RGB <-> HSV conversion impl based on Python `colorsys` module. */
16 | 
17 | /// Converts an RGB pixel value in [0, 1] to HSV.
18 | pub(crate) fn convert_rgb_to_hsv((r, g, b): (f32, f32, f32)) -> (f32, f32, f32) {
19 |     let max = max(r, max(g, b));
20 |     let min = min(r, min(g, b));
21 |     let v = max;
22 |     if min == max {
23 |         return (0.0, 0.0, v);
24 |     }
25 | 
26 |     let s = (max - min) / max;
27 |     let r = (max - r) / (max - min);
28 |     let g = (max - g) / (max - min);
29 |     let b = (max - b) / (max - min);
30 |     let h = if r == max {
31 |         b - g
32 |     } else if g == max {
33 |         2.0 + r - b
34 |     } else {
35 |         4.0 + g - r
36 |     };
37 | 
38 |     return (h / 6.0, s, v);
39 | }
40 | 
41 | /// Converts a HSV pixel value to RGB (in range [0, 1]).
42 | pub(crate) fn convert_hsv_to_rgb((h, s, v): (f32, f32, f32)) -> (f32, f32, f32) {
43 |     if s == 0.0 {
44 |         return (v, v, v);
45 |     }
46 | 
47 |     let i = (h * 6.0) as u8;
48 |     let f = (h * 6.0) - (h * 6.0).floor();
49 |     let p = v * (1.0 - s);
50 |     let q = v * (1.0 - s * f);
51 |     let t = v * (1.0 - s * (1.0 - f));
52 | 
53 |     match i % 6 {
54 |         0 => (v, t, p),
55 |         1 => (q, v, p),
56 |         2 => (p, v, t),
57 |         3 => (p, q, v),
58 |         4 => (t, p, v),
59 |         5 => (v, p, q),
60 |         _ => unreachable!("bleh?!?@!"),
61 |     }
62 | }
63 | 
64 | /* min/max workaround for floats */
65 | 
66 | #[inline]
67 | fn max(v1: f32, v2: f32) -> f32 {
68 |     if v1 > v2 {
69 |         v1
70 |     } else {
71 |         v2
72 |     }
73 | }
74 | 
75 | #[inline]
76 | fn min(v1: f32, v2: f32) -> f32 {
77 |     if v1 < v2 {
78 |         v1
79 |     } else {
80 |         v2
81 |     }
82 | }
83 | 


--------------------------------------------------------------------------------