From 076101794cfe764f18fbd77a35830aceea67de55 Mon Sep 17 00:00:00 2001 From: AdrianGroty <40133867+AdrianGroty@users.noreply.github.com> Date: Thu, 15 Jan 2026 17:29:10 -0600 Subject: [PATCH 01/11] Add files via upload --- samples/yinyang256.ddw | 501 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 501 insertions(+) create mode 100644 samples/yinyang256.ddw diff --git a/samples/yinyang256.ddw b/samples/yinyang256.ddw new file mode 100644 index 0000000..aa10c8d --- /dev/null +++ b/samples/yinyang256.ddw @@ -0,0 +1,501 @@ +{"id": "1", "type": "frame", "x": null, "y": null, "text": null, "color": null, "tags": null, "group": null, "frame": null, "rows": null, "duration_ms": 83, "ref": null} +{"x": 12, "y": 0, "text": "\u25e5", "color": "53 on 0", "frame": "1"} +{"x": 13, "y": 0, "text": "\u2588", "color": "54 on 0", "frame": "1"} +{"x": 14, "y": 0, "text": "\ud83e\udf86", "color": "54 on 0", "frame": "1"} +{"x": 15, "y": 0, "text": "\ud83e\udf85", "color": "55 on 0", "frame": "1"} +{"x": 16, "y": 0, "text": "\ud83e\udf84", "color": "55 on 0", "frame": "1"} +{"x": 17, "y": 0, "text": "\ud83e\udf84", "color": "56 on 0", "frame": "1"} +{"x": 18, "y": 0, "text": "\u2580", "color": "56 on 0", "frame": "1"} +{"x": 19, "y": 0, "text": "\u2580", "color": "57 on 0", "frame": "1"} +{"x": 20, "y": 0, "text": "\u2580", "color": "57 on 0", "frame": "1"} +{"x": 21, "y": 0, "text": "\u2580", "color": "63 on 0", "frame": "1"} +{"x": 22, "y": 0, "text": "\u2580", "color": "63 on 0", "frame": "1"} +{"x": 23, "y": 0, "text": "\ud83e\udf84", "color": "99 on 0", "frame": "1"} +{"x": 24, "y": 0, "text": "\ud83e\udf84", "color": "99 on 0", "frame": "1"} +{"x": 25, "y": 0, "text": "\ud83e\udf85", "color": "135 on 0", "frame": "1"} +{"x": 26, "y": 0, "text": "\ud83e\udf86", "color": "135 on 0", "frame": "1"} +{"x": 27, "y": 0, "text": "\u2588", "color": "171 on 0", "frame": "1"} +{"x": 28, "y": 0, "text": "\u2588", "color": "171 on 0", "frame": "1"} +{"x": 29, "y": 0, "text": "\u2588", "color": "207 on 0", "frame": "1"} +{"x": 30, "y": 0, "text": "\u2588", "color": "207 on 0", "frame": "1"} +{"x": 31, "y": 0, "text": "\u2588", "color": "206 on 0", "frame": "1"} +{"x": 32, "y": 0, "text": "\u2588", "color": "206 on 0", "frame": "1"} +{"x": 33, "y": 0, "text": "\u2588", "color": "205 on 0", "frame": "1"} +{"x": 34, "y": 0, "text": "\u2588", "color": "205 on 0", "frame": "1"} +{"x": 35, "y": 0, "text": "\u2588", "color": "204 on 0", "frame": "1"} +{"x": 36, "y": 0, "text": "\u2588", "color": "204 on 0", "frame": "1"} +{"x": 37, "y": 0, "text": "\u2588", "color": "203 on 0", "frame": "1"} +{"x": 38, "y": 0, "text": "\u2588", "color": "203 on 0", "frame": "1"} +{"x": 39, "y": 0, "text": "\u2588", "color": "202 on 0", "frame": "1"} +{"x": 40, "y": 0, "text": "\u2588", "color": "202 on 0", "frame": "1"} +{"x": 10, "y": 1, "text": "\ud83e\udf48", "color": "250 on 0", "frame": "1"} +{"x": 11, "y": 1, "text": "\ud83e\udf46", "color": "249 on 0", "frame": "1"} +{"x": 12, "y": 1, "text": "\u2586", "color": "248 on 0", "frame": "1"} +{"x": 13, "y": 1, "text": "\u2587", "color": "247 on 0", "frame": "1"} +{"x": 14, "y": 1, "text": "\u2588", "color": "246 on 0", "frame": "1"} +{"x": 15, "y": 1, "text": "\u2588", "color": "245 on 0", "frame": "1"} +{"x": 16, "y": 1, "text": "\u2588", "color": "244 on 0", "frame": "1"} +{"x": 17, "y": 1, "text": "\u2588", "color": "243 on 0", "frame": "1"} +{"x": 18, "y": 1, "text": "\u2588", "color": "242 on 0", "frame": "1"} +{"x": 19, "y": 1, "text": "\u2588", "color": "241 on 0", "frame": "1"} +{"x": 20, "y": 1, "text": "\u2588", "color": "240 on 0", "frame": "1"} +{"x": 21, "y": 1, "text": "\u2587", "color": "239 on 0", "frame": "1"} +{"x": 22, "y": 1, "text": "\u2586", "color": "238 on 0", "frame": "1"} +{"x": 23, "y": 1, "text": "\ud83e\udf51", "color": "237 on 0", "frame": "1"} +{"x": 24, "y": 1, "text": "\ud83e\udf3d", "color": "236 on 0", "frame": "1"} +{"x": 28, "y": 1, "text": "\ud83e\udf82", "color": "171 on 0", "frame": "1"} +{"x": 29, "y": 1, "text": "\ud83e\udf67", "color": "207 on 0", "frame": "1"} +{"x": 30, "y": 1, "text": "\ud83e\udf53", "color": "207 on 0", "frame": "1"} +{"x": 31, "y": 1, "text": "\u2588", "color": "206 on 0", "frame": "1"} +{"x": 32, "y": 1, "text": "\u2588", "color": "206 on 0", "frame": "1"} +{"x": 33, "y": 1, "text": "\u2588", "color": "205 on 0", "frame": "1"} +{"x": 34, "y": 1, "text": "\u2588", "color": "205 on 0", "frame": "1"} +{"x": 35, "y": 1, "text": "\u2588", "color": "204 on 0", "frame": "1"} +{"x": 36, "y": 1, "text": "\u2588", "color": "204 on 0", "frame": "1"} +{"x": 37, "y": 1, "text": "\u2588", "color": "203 on 0", "frame": "1"} +{"x": 38, "y": 1, "text": "\u2588", "color": "203 on 0", "frame": "1"} +{"x": 39, "y": 1, "text": "\u2588", "color": "202 on 0", "frame": "1"} +{"x": 40, "y": 1, "text": "\u2588", "color": "202 on 0", "frame": "1"} +{"x": 7, "y": 2, "text": "\ud83e\udf48", "color": "253 on 0", "frame": "1"} +{"x": 8, "y": 2, "text": "\ud83e\udf46", "color": "252 on 0", "frame": "1"} +{"x": 9, "y": 2, "text": "\ud83e\udf42", "color": "251 on 0", "frame": "1"} +{"x": 10, "y": 2, "text": "\u2588", "color": "250 on 0", "frame": "1"} +{"x": 11, "y": 2, "text": "\u2588", "color": "249 on 0", "frame": "1"} +{"x": 12, "y": 2, "text": "\u2588", "color": "248 on 0", "frame": "1"} +{"x": 13, "y": 2, "text": "\u2588", "color": "247 on 0", "frame": "1"} +{"x": 14, "y": 2, "text": "\u2588", "color": "246 on 0", "frame": "1"} +{"x": 15, "y": 2, "text": "\u2588", "color": "245 on 0", "frame": "1"} +{"x": 16, "y": 2, "text": "\u2588", "color": "244 on 0", "frame": "1"} +{"x": 17, "y": 2, "text": "\u2588", "color": "243 on 0", "frame": "1"} +{"x": 18, "y": 2, "text": "\u2588", "color": "242 on 0", "frame": "1"} +{"x": 19, "y": 2, "text": "\u2588", "color": "241 on 0", "frame": "1"} +{"x": 20, "y": 2, "text": "\u2588", "color": "240 on 0", "frame": "1"} +{"x": 21, "y": 2, "text": "\u2588", "color": "239 on 0", "frame": "1"} +{"x": 22, "y": 2, "text": "\u2588", "color": "238 on 0", "frame": "1"} +{"x": 23, "y": 2, "text": "\u2588", "color": "237 on 0", "frame": "1"} +{"x": 24, "y": 2, "text": "\u2588", "color": "236 on 0", "frame": "1"} +{"x": 25, "y": 2, "text": "\ud83e\udf4f", "color": "235 on 0", "frame": "1"} +{"x": 26, "y": 2, "text": "\ud83e\udf3c", "color": "235 on 0", "frame": "1"} +{"x": 31, "y": 2, "text": "\ud83e\udf63", "color": "206 on 0", "frame": "1"} +{"x": 32, "y": 2, "text": "\ud83e\udf67", "color": "206 on 0", "frame": "1"} +{"x": 33, "y": 2, "text": "\ud83e\udf53", "color": "205 on 0", "frame": "1"} +{"x": 34, "y": 2, "text": "\u2588", "color": "205 on 0", "frame": "1"} +{"x": 35, "y": 2, "text": "\u2588", "color": "204 on 0", "frame": "1"} +{"x": 36, "y": 2, "text": "\u2588", "color": "204 on 0", "frame": "1"} +{"x": 37, "y": 2, "text": "\u2588", "color": "203 on 0", "frame": "1"} +{"x": 38, "y": 2, "text": "\u2588", "color": "203 on 0", "frame": "1"} +{"x": 39, "y": 2, "text": "\u2588", "color": "202 on 0", "frame": "1"} +{"x": 40, "y": 2, "text": "\u2588", "color": "202 on 0", "frame": "1"} +{"x": 5, "y": 3, "text": "\ud83e\udf47", "color": "255 on 0", "frame": "1"} +{"x": 6, "y": 3, "text": "\ud83e\udf44", "color": "254 on 0", "frame": "1"} +{"x": 7, "y": 3, "text": "\u2588", "color": "253 on 0", "frame": "1"} +{"x": 8, "y": 3, "text": "\u2588", "color": "252 on 0", "frame": "1"} +{"x": 9, "y": 3, "text": "\u2588", "color": "251 on 0", "frame": "1"} +{"x": 10, "y": 3, "text": "\u2588", "color": "250 on 0", "frame": "1"} +{"x": 11, "y": 3, "text": "\u2588", "color": "249 on 0", "frame": "1"} +{"x": 12, "y": 3, "text": "\u2588", "color": "248 on 0", "frame": "1"} +{"x": 13, "y": 3, "text": "\u2588", "color": "247 on 0", "frame": "1"} +{"x": 14, "y": 3, "text": "\u2588", "color": "246 on 0", "frame": "1"} +{"x": 15, "y": 3, "text": "\u2588", "color": "245 on 0", "frame": "1"} +{"x": 16, "y": 3, "text": "\u2588", "color": "244 on 0", "frame": "1"} +{"x": 17, "y": 3, "text": "\u2588", "color": "243 on 0", "frame": "1"} +{"x": 18, "y": 3, "text": "\u2588", "color": "242 on 0", "frame": "1"} +{"x": 19, "y": 3, "text": "\u2588", "color": "241 on 0", "frame": "1"} +{"x": 20, "y": 3, "text": "\u2588", "color": "240 on 0", "frame": "1"} +{"x": 21, "y": 3, "text": "\u2588", "color": "239 on 0", "frame": "1"} +{"x": 22, "y": 3, "text": "\u2588", "color": "238 on 0", "frame": "1"} +{"x": 23, "y": 3, "text": "\u2588", "color": "237 on 0", "frame": "1"} +{"x": 24, "y": 3, "text": "\u2588", "color": "236 on 0", "frame": "1"} +{"x": 25, "y": 3, "text": "\u2588", "color": "235 on 0", "frame": "1"} +{"x": 26, "y": 3, "text": "\ud83e\udf4c", "color": "234 on 0", "frame": "1"} +{"x": 27, "y": 3, "text": "\ud83e\udf3e", "color": "233 on 0", "frame": "1"} +{"x": 34, "y": 3, "text": "\ud83e\udf65", "color": "205 on 0", "frame": "1"} +{"x": 35, "y": 3, "text": "\ud83e\udf52", "color": "204 on 0", "frame": "1"} +{"x": 36, "y": 3, "text": "\u2588", "color": "204 on 0", "frame": "1"} +{"x": 37, "y": 3, "text": "\u2588", "color": "203 on 0", "frame": "1"} +{"x": 38, "y": 3, "text": "\u2588", "color": "203 on 0", "frame": "1"} +{"x": 39, "y": 3, "text": "\u2588", "color": "202 on 0", "frame": "1"} +{"x": 40, "y": 3, "text": "\u2588", "color": "202 on 0", "frame": "1"} +{"x": 4, "y": 4, "text": "\ud83e\udf4a", "color": "255 on 0", "frame": "1"} +{"x": 5, "y": 4, "text": "\ud83e\udf41", "color": "255 on 0", "frame": "1"} +{"x": 6, "y": 4, "text": "\u2588", "color": "254 on 0", "frame": "1"} +{"x": 7, "y": 4, "text": "\u2588", "color": "253 on 0", "frame": "1"} +{"x": 8, "y": 4, "text": "\u2588", "color": "252 on 0", "frame": "1"} +{"x": 9, "y": 4, "text": "\u2588", "color": "251 on 0", "frame": "1"} +{"x": 10, "y": 4, "text": "\u2588", "color": "250 on 0", "frame": "1"} +{"x": 11, "y": 4, "text": "\u2588", "color": "249 on 0", "frame": "1"} +{"x": 12, "y": 4, "text": "\u2588", "color": "248 on 0", "frame": "1"} +{"x": 13, "y": 4, "text": "\u2588", "color": "247 on 0", "frame": "1"} +{"x": 14, "y": 4, "text": "\u2588", "color": "246 on 0", "frame": "1"} +{"x": 15, "y": 4, "text": "\u2588", "color": "245 on 0", "frame": "1"} +{"x": 16, "y": 4, "text": "\u2588", "color": "244 on 0", "frame": "1"} +{"x": 17, "y": 4, "text": "\u2588", "color": "243 on 0", "frame": "1"} +{"x": 18, "y": 4, "text": "\ud83e\udf60", "color": "242 on 0", "frame": "1"} +{"x": 19, "y": 4, "text": "\ud83e\udf58", "color": "241 on 0", "frame": "1"} +{"x": 21, "y": 4, "text": "\ud83e\udf63", "color": "239 on 0", "frame": "1"} +{"x": 22, "y": 4, "text": "\ud83e\udf55", "color": "238 on 0", "frame": "1"} +{"x": 23, "y": 4, "text": "\u2588", "color": "237 on 0", "frame": "1"} +{"x": 24, "y": 4, "text": "\u2588", "color": "236 on 0", "frame": "1"} +{"x": 25, "y": 4, "text": "\u2588", "color": "235 on 0", "frame": "1"} +{"x": 26, "y": 4, "text": "\u2588", "color": "234 on 0", "frame": "1"} +{"x": 27, "y": 4, "text": "\ud83e\udf50", "color": "233 on 0", "frame": "1"} +{"x": 35, "y": 4, "text": "\ud83e\udf62", "color": "204 on 0", "frame": "1"} +{"x": 36, "y": 4, "text": "\ud83e\udf55", "color": "204 on 0", "frame": "1"} +{"x": 37, "y": 4, "text": "\u2588", "color": "203 on 0", "frame": "1"} +{"x": 38, "y": 4, "text": "\u2588", "color": "203 on 0", "frame": "1"} +{"x": 39, "y": 4, "text": "\u2588", "color": "202 on 0", "frame": "1"} +{"x": 40, "y": 4, "text": "\u2588", "color": "202 on 0", "frame": "1"} +{"x": 3, "y": 5, "text": "\u25e2", "color": "255 on 0", "frame": "1"} +{"x": 4, "y": 5, "text": "\u2588", "color": "255 on 0", "frame": "1"} +{"x": 5, "y": 5, "text": "\u2588", "color": "255 on 0", "frame": "1"} +{"x": 6, "y": 5, "text": "\u2588", "color": "254 on 0", "frame": "1"} +{"x": 7, "y": 5, "text": "\u2588", "color": "253 on 0", "frame": "1"} +{"x": 8, "y": 5, "text": "\u2588", "color": "252 on 0", "frame": "1"} +{"x": 9, "y": 5, "text": "\u2588", "color": "251 on 0", "frame": "1"} +{"x": 10, "y": 5, "text": "\u2588", "color": "250 on 0", "frame": "1"} +{"x": 11, "y": 5, "text": "\u2588", "color": "249 on 0", "frame": "1"} +{"x": 12, "y": 5, "text": "\u2588", "color": "248 on 0", "frame": "1"} +{"x": 13, "y": 5, "text": "\u2588", "color": "247 on 0", "frame": "1"} +{"x": 14, "y": 5, "text": "\u2588", "color": "246 on 0", "frame": "1"} +{"x": 15, "y": 5, "text": "\u2588", "color": "245 on 0", "frame": "1"} +{"x": 16, "y": 5, "text": "\u2588", "color": "244 on 0", "frame": "1"} +{"x": 17, "y": 5, "text": "\u2588", "color": "243 on 0", "frame": "1"} +{"x": 23, "y": 5, "text": "\u2588", "color": "237 on 0", "frame": "1"} +{"x": 24, "y": 5, "text": "\u2588", "color": "236 on 0", "frame": "1"} +{"x": 25, "y": 5, "text": "\u2588", "color": "235 on 0", "frame": "1"} +{"x": 26, "y": 5, "text": "\u2588", "color": "234 on 0", "frame": "1"} +{"x": 27, "y": 5, "text": "\u2588", "color": "233 on 0", "frame": "1"} +{"x": 37, "y": 5, "text": "\u25e5", "color": "203 on 0", "frame": "1"} +{"x": 38, "y": 5, "text": "\u2588", "color": "203 on 0", "frame": "1"} +{"x": 39, "y": 5, "text": "\u2588", "color": "202 on 0", "frame": "1"} +{"x": 40, "y": 5, "text": "\u2588", "color": "202 on 0", "frame": "1"} +{"x": 2, "y": 6, "text": "\u25e2", "color": "255 on 0", "frame": "1"} +{"x": 3, "y": 6, "text": "\u2588", "color": "255 on 0", "frame": "1"} +{"x": 4, "y": 6, "text": "\u2588", "color": "255 on 0", "frame": "1"} +{"x": 5, "y": 6, "text": "\u2588", "color": "255 on 0", "frame": "1"} +{"x": 6, "y": 6, "text": "\u2588", "color": "254 on 0", "frame": "1"} +{"x": 7, "y": 6, "text": "\u2588", "color": "253 on 0", "frame": "1"} +{"x": 8, "y": 6, "text": "\u2588", "color": "252 on 0", "frame": "1"} +{"x": 9, "y": 6, "text": "\u2588", "color": "251 on 0", "frame": "1"} +{"x": 10, "y": 6, "text": "\u2588", "color": "250 on 0", "frame": "1"} +{"x": 11, "y": 6, "text": "\u2588", "color": "249 on 0", "frame": "1"} +{"x": 12, "y": 6, "text": "\u2588", "color": "248 on 0", "frame": "1"} +{"x": 13, "y": 6, "text": "\u2588", "color": "247 on 0", "frame": "1"} +{"x": 14, "y": 6, "text": "\u2588", "color": "246 on 0", "frame": "1"} +{"x": 15, "y": 6, "text": "\u2588", "color": "245 on 0", "frame": "1"} +{"x": 16, "y": 6, "text": "\u2588", "color": "244 on 0", "frame": "1"} +{"x": 17, "y": 6, "text": "\u2588", "color": "243 on 0", "frame": "1"} +{"x": 18, "y": 6, "text": "\ud83e\udf4f", "color": "242 on 0", "frame": "1"} +{"x": 19, "y": 6, "text": "\ud83e\udf3d", "color": "241 on 0", "frame": "1"} +{"x": 21, "y": 6, "text": "\ud83e\udf48", "color": "239 on 0", "frame": "1"} +{"x": 22, "y": 6, "text": "\ud83e\udf44", "color": "238 on 0", "frame": "1"} +{"x": 23, "y": 6, "text": "\u2588", "color": "237 on 0", "frame": "1"} +{"x": 24, "y": 6, "text": "\u2588", "color": "236 on 0", "frame": "1"} +{"x": 25, "y": 6, "text": "\u2588", "color": "235 on 0", "frame": "1"} +{"x": 26, "y": 6, "text": "\u2588", "color": "234 on 0", "frame": "1"} +{"x": 27, "y": 6, "text": "\ud83e\udf61", "color": "233 on 0", "frame": "1"} +{"x": 38, "y": 6, "text": "\u25e5", "color": "203 on 0", "frame": "1"} +{"x": 39, "y": 6, "text": "\u2588", "color": "202 on 0", "frame": "1"} +{"x": 40, "y": 6, "text": "\u2588", "color": "202 on 0", "frame": "1"} +{"x": 1, "y": 7, "text": "\ud83e\udf4b", "color": "255 on 0", "frame": "1"} +{"x": 2, "y": 7, "text": "\u2588", "color": "255 on 0", "frame": "1"} +{"x": 3, "y": 7, "text": "\u2588", "color": "255 on 0", "frame": "1"} +{"x": 4, "y": 7, "text": "\u2588", "color": "255 on 0", "frame": "1"} +{"x": 5, "y": 7, "text": "\u2588", "color": "255 on 0", "frame": "1"} +{"x": 6, "y": 7, "text": "\u2588", "color": "254 on 0", "frame": "1"} +{"x": 7, "y": 7, "text": "\u2588", "color": "253 on 0", "frame": "1"} +{"x": 8, "y": 7, "text": "\u2588", "color": "252 on 0", "frame": "1"} +{"x": 9, "y": 7, "text": "\u2588", "color": "251 on 0", "frame": "1"} +{"x": 10, "y": 7, "text": "\u2588", "color": "250 on 0", "frame": "1"} +{"x": 11, "y": 7, "text": "\u2588", "color": "249 on 0", "frame": "1"} +{"x": 12, "y": 7, "text": "\u2588", "color": "248 on 0", "frame": "1"} +{"x": 13, "y": 7, "text": "\u2588", "color": "247 on 0", "frame": "1"} +{"x": 14, "y": 7, "text": "\u2588", "color": "246 on 0", "frame": "1"} +{"x": 15, "y": 7, "text": "\u2588", "color": "245 on 0", "frame": "1"} +{"x": 16, "y": 7, "text": "\u2588", "color": "244 on 0", "frame": "1"} +{"x": 17, "y": 7, "text": "\u2588", "color": "243 on 0", "frame": "1"} +{"x": 18, "y": 7, "text": "\u2588", "color": "242 on 0", "frame": "1"} +{"x": 19, "y": 7, "text": "\u2588", "color": "241 on 0", "frame": "1"} +{"x": 20, "y": 7, "text": "\u2588", "color": "240 on 0", "frame": "1"} +{"x": 21, "y": 7, "text": "\u2588", "color": "239 on 0", "frame": "1"} +{"x": 22, "y": 7, "text": "\u2588", "color": "238 on 0", "frame": "1"} +{"x": 23, "y": 7, "text": "\u2588", "color": "237 on 0", "frame": "1"} +{"x": 24, "y": 7, "text": "\u2588", "color": "236 on 0", "frame": "1"} +{"x": 25, "y": 7, "text": "\u2588", "color": "235 on 0", "frame": "1"} +{"x": 26, "y": 7, "text": "\ud83e\udf5d", "color": "234 on 0", "frame": "1"} +{"x": 27, "y": 7, "text": "\ud83e\udf59", "color": "233 on 0", "frame": "1"} +{"x": 39, "y": 7, "text": "\ud83e\udf56", "color": "202 on 0", "frame": "1"} +{"x": 40, "y": 7, "text": "\u2588", "color": "202 on 0", "frame": "1"} +{"x": 1, "y": 8, "text": "\ud83e\udf45", "color": "255 on 0", "frame": "1"} +{"x": 2, "y": 8, "text": "\u2588", "color": "255 on 0", "frame": "1"} +{"x": 3, "y": 8, "text": "\u2588", "color": "255 on 0", "frame": "1"} +{"x": 4, "y": 8, "text": "\u2588", "color": "255 on 0", "frame": "1"} +{"x": 5, "y": 8, "text": "\u2588", "color": "255 on 0", "frame": "1"} +{"x": 6, "y": 8, "text": "\u2588", "color": "254 on 0", "frame": "1"} +{"x": 7, "y": 8, "text": "\u2588", "color": "253 on 0", "frame": "1"} +{"x": 8, "y": 8, "text": "\u2588", "color": "252 on 0", "frame": "1"} +{"x": 9, "y": 8, "text": "\u2588", "color": "251 on 0", "frame": "1"} +{"x": 10, "y": 8, "text": "\u2588", "color": "250 on 0", "frame": "1"} +{"x": 11, "y": 8, "text": "\u2588", "color": "249 on 0", "frame": "1"} +{"x": 12, "y": 8, "text": "\u2588", "color": "248 on 0", "frame": "1"} +{"x": 13, "y": 8, "text": "\u2588", "color": "247 on 0", "frame": "1"} +{"x": 14, "y": 8, "text": "\u2588", "color": "246 on 0", "frame": "1"} +{"x": 15, "y": 8, "text": "\u2588", "color": "245 on 0", "frame": "1"} +{"x": 16, "y": 8, "text": "\u2588", "color": "244 on 0", "frame": "1"} +{"x": 17, "y": 8, "text": "\u2588", "color": "243 on 0", "frame": "1"} +{"x": 18, "y": 8, "text": "\u2588", "color": "242 on 0", "frame": "1"} +{"x": 19, "y": 8, "text": "\u2588", "color": "241 on 0", "frame": "1"} +{"x": 20, "y": 8, "text": "\u2588", "color": "240 on 0", "frame": "1"} +{"x": 21, "y": 8, "text": "\u2588", "color": "239 on 0", "frame": "1"} +{"x": 22, "y": 8, "text": "\u2588", "color": "238 on 0", "frame": "1"} +{"x": 23, "y": 8, "text": "\u2588", "color": "237 on 0", "frame": "1"} +{"x": 24, "y": 8, "text": "\u2588", "color": "236 on 0", "frame": "1"} +{"x": 25, "y": 8, "text": "\ud83e\udf60", "color": "235 on 0", "frame": "1"} +{"x": 26, "y": 8, "text": "\ud83e\udf57", "color": "234 on 0", "frame": "1"} +{"x": 39, "y": 8, "text": "\ud83e\udf66", "color": "202 on 0", "frame": "1"} +{"x": 40, "y": 8, "text": "\u2588", "color": "202 on 0", "frame": "1"} +{"x": 1, "y": 9, "text": "\u2588", "color": "255 on 0", "frame": "1"} +{"x": 2, "y": 9, "text": "\u2588", "color": "255 on 0", "frame": "1"} +{"x": 3, "y": 9, "text": "\u2588", "color": "255 on 0", "frame": "1"} +{"x": 4, "y": 9, "text": "\u2588", "color": "255 on 0", "frame": "1"} +{"x": 5, "y": 9, "text": "\u2588", "color": "255 on 0", "frame": "1"} +{"x": 6, "y": 9, "text": "\u2588", "color": "254 on 0", "frame": "1"} +{"x": 7, "y": 9, "text": "\u2588", "color": "253 on 0", "frame": "1"} +{"x": 8, "y": 9, "text": "\u2588", "color": "252 on 0", "frame": "1"} +{"x": 9, "y": 9, "text": "\u2588", "color": "251 on 0", "frame": "1"} +{"x": 10, "y": 9, "text": "\u2588", "color": "250 on 0", "frame": "1"} +{"x": 11, "y": 9, "text": "\u2588", "color": "249 on 0", "frame": "1"} +{"x": 12, "y": 9, "text": "\u2588", "color": "248 on 0", "frame": "1"} +{"x": 13, "y": 9, "text": "\u2588", "color": "247 on 0", "frame": "1"} +{"x": 14, "y": 9, "text": "\u2588", "color": "246 on 0", "frame": "1"} +{"x": 15, "y": 9, "text": "\u2588", "color": "245 on 0", "frame": "1"} +{"x": 16, "y": 9, "text": "\u2588", "color": "244 on 0", "frame": "1"} +{"x": 17, "y": 9, "text": "\u2588", "color": "243 on 0", "frame": "1"} +{"x": 18, "y": 9, "text": "\u2588", "color": "242 on 0", "frame": "1"} +{"x": 19, "y": 9, "text": "\u2588", "color": "241 on 0", "frame": "1"} +{"x": 20, "y": 9, "text": "\u2588", "color": "240 on 0", "frame": "1"} +{"x": 21, "y": 9, "text": "\u2588", "color": "239 on 0", "frame": "1"} +{"x": 22, "y": 9, "text": "\ud83e\udf5e", "color": "238 on 0", "frame": "1"} +{"x": 23, "y": 9, "text": "\ud83e\udf5c", "color": "237 on 0", "frame": "1"} +{"x": 24, "y": 9, "text": "\ud83e\udf58", "color": "236 on 0", "frame": "1"} +{"x": 40, "y": 9, "text": "\u2588", "color": "202 on 0", "frame": "1"} +{"x": 1, "y": 10, "text": "\u2588", "color": "255 on 0", "frame": "1"} +{"x": 2, "y": 10, "text": "\u2588", "color": "255 on 0", "frame": "1"} +{"x": 3, "y": 10, "text": "\u2588", "color": "255 on 0", "frame": "1"} +{"x": 4, "y": 10, "text": "\u2588", "color": "255 on 0", "frame": "1"} +{"x": 5, "y": 10, "text": "\u2588", "color": "255 on 0", "frame": "1"} +{"x": 6, "y": 10, "text": "\u2588", "color": "254 on 0", "frame": "1"} +{"x": 7, "y": 10, "text": "\u2588", "color": "253 on 0", "frame": "1"} +{"x": 8, "y": 10, "text": "\u2588", "color": "252 on 0", "frame": "1"} +{"x": 9, "y": 10, "text": "\u2588", "color": "251 on 0", "frame": "1"} +{"x": 10, "y": 10, "text": "\u2588", "color": "250 on 0", "frame": "1"} +{"x": 11, "y": 10, "text": "\u2588", "color": "249 on 0", "frame": "1"} +{"x": 12, "y": 10, "text": "\u2588", "color": "248 on 0", "frame": "1"} +{"x": 13, "y": 10, "text": "\u2588", "color": "247 on 0", "frame": "1"} +{"x": 14, "y": 10, "text": "\u2588", "color": "246 on 0", "frame": "1"} +{"x": 15, "y": 10, "text": "\u2588", "color": "245 on 0", "frame": "1"} +{"x": 16, "y": 10, "text": "\u2588", "color": "244 on 0", "frame": "1"} +{"x": 17, "y": 10, "text": "\u2588", "color": "243 on 0", "frame": "1"} +{"x": 18, "y": 10, "text": "\u2588", "color": "242 on 0", "frame": "1"} +{"x": 19, "y": 10, "text": "\ud83e\udf5e", "color": "241 on 0", "frame": "1"} +{"x": 20, "y": 10, "text": "\ud83e\udf5c", "color": "240 on 0", "frame": "1"} +{"x": 21, "y": 10, "text": "\ud83e\udf58", "color": "239 on 0", "frame": "1"} +{"x": 40, "y": 10, "text": "\u2588", "color": "202 on 0", "frame": "1"} +{"x": 1, "y": 11, "text": "\u2588", "color": "255 on 0", "frame": "1"} +{"x": 2, "y": 11, "text": "\u2588", "color": "255 on 0", "frame": "1"} +{"x": 3, "y": 11, "text": "\u2588", "color": "255 on 0", "frame": "1"} +{"x": 4, "y": 11, "text": "\u2588", "color": "255 on 0", "frame": "1"} +{"x": 5, "y": 11, "text": "\u2588", "color": "255 on 0", "frame": "1"} +{"x": 6, "y": 11, "text": "\u2588", "color": "254 on 0", "frame": "1"} +{"x": 7, "y": 11, "text": "\u2588", "color": "253 on 0", "frame": "1"} +{"x": 8, "y": 11, "text": "\u2588", "color": "252 on 0", "frame": "1"} +{"x": 9, "y": 11, "text": "\u2588", "color": "251 on 0", "frame": "1"} +{"x": 10, "y": 11, "text": "\u2588", "color": "250 on 0", "frame": "1"} +{"x": 11, "y": 11, "text": "\u2588", "color": "249 on 0", "frame": "1"} +{"x": 12, "y": 11, "text": "\u2588", "color": "248 on 0", "frame": "1"} +{"x": 13, "y": 11, "text": "\u2588", "color": "247 on 0", "frame": "1"} +{"x": 14, "y": 11, "text": "\u2588", "color": "246 on 0", "frame": "1"} +{"x": 15, "y": 11, "text": "\u2588", "color": "245 on 0", "frame": "1"} +{"x": 16, "y": 11, "text": "\ud83e\udf5e", "color": "244 on 0", "frame": "1"} +{"x": 17, "y": 11, "text": "\ud83e\udf5c", "color": "243 on 0", "frame": "1"} +{"x": 18, "y": 11, "text": "\ud83e\udf58", "color": "242 on 0", "frame": "1"} +{"x": 40, "y": 11, "text": "\u2588", "color": "202 on 0", "frame": "1"} +{"x": 1, "y": 12, "text": "\ud83e\udf56", "color": "255 on 0", "frame": "1"} +{"x": 2, "y": 12, "text": "\u2588", "color": "255 on 0", "frame": "1"} +{"x": 3, "y": 12, "text": "\u2588", "color": "255 on 0", "frame": "1"} +{"x": 4, "y": 12, "text": "\u2588", "color": "255 on 0", "frame": "1"} +{"x": 5, "y": 12, "text": "\u2588", "color": "255 on 0", "frame": "1"} +{"x": 6, "y": 12, "text": "\u2588", "color": "254 on 0", "frame": "1"} +{"x": 7, "y": 12, "text": "\u2588", "color": "253 on 0", "frame": "1"} +{"x": 8, "y": 12, "text": "\u2588", "color": "252 on 0", "frame": "1"} +{"x": 9, "y": 12, "text": "\u2588", "color": "251 on 0", "frame": "1"} +{"x": 10, "y": 12, "text": "\u2588", "color": "250 on 0", "frame": "1"} +{"x": 11, "y": 12, "text": "\u2588", "color": "249 on 0", "frame": "1"} +{"x": 12, "y": 12, "text": "\u2588", "color": "248 on 0", "frame": "1"} +{"x": 13, "y": 12, "text": "\u2588", "color": "247 on 0", "frame": "1"} +{"x": 14, "y": 12, "text": "\ud83e\udf5d", "color": "246 on 0", "frame": "1"} +{"x": 15, "y": 12, "text": "\ud83e\udf5a", "color": "245 on 0", "frame": "1"} +{"x": 39, "y": 12, "text": "\ud83e\udf4b", "color": "202 on 0", "frame": "1"} +{"x": 40, "y": 12, "text": "\u2588", "color": "202 on 0", "frame": "1"} +{"x": 1, "y": 13, "text": "\ud83e\udf66", "color": "255 on 0", "frame": "1"} +{"x": 2, "y": 13, "text": "\u2588", "color": "255 on 0", "frame": "1"} +{"x": 3, "y": 13, "text": "\u2588", "color": "255 on 0", "frame": "1"} +{"x": 4, "y": 13, "text": "\u2588", "color": "255 on 0", "frame": "1"} +{"x": 5, "y": 13, "text": "\u2588", "color": "255 on 0", "frame": "1"} +{"x": 6, "y": 13, "text": "\u2588", "color": "254 on 0", "frame": "1"} +{"x": 7, "y": 13, "text": "\u2588", "color": "253 on 0", "frame": "1"} +{"x": 8, "y": 13, "text": "\u2588", "color": "252 on 0", "frame": "1"} +{"x": 9, "y": 13, "text": "\u2588", "color": "251 on 0", "frame": "1"} +{"x": 10, "y": 13, "text": "\u2588", "color": "250 on 0", "frame": "1"} +{"x": 11, "y": 13, "text": "\u2588", "color": "249 on 0", "frame": "1"} +{"x": 12, "y": 13, "text": "\u2588", "color": "248 on 0", "frame": "1"} +{"x": 13, "y": 13, "text": "\ud83e\udf5f", "color": "247 on 0", "frame": "1"} +{"x": 14, "y": 13, "text": "\ud83e\udf57", "color": "246 on 0", "frame": "1"} +{"x": 39, "y": 13, "text": "\ud83e\udf45", "color": "202 on 0", "frame": "1"} +{"x": 40, "y": 13, "text": "\u2588", "color": "202 on 0", "frame": "1"} +{"x": 2, "y": 14, "text": "\u25e5", "color": "255 on 0", "frame": "1"} +{"x": 3, "y": 14, "text": "\u2588", "color": "255 on 0", "frame": "1"} +{"x": 4, "y": 14, "text": "\u2588", "color": "255 on 0", "frame": "1"} +{"x": 5, "y": 14, "text": "\u2588", "color": "255 on 0", "frame": "1"} +{"x": 6, "y": 14, "text": "\u2588", "color": "254 on 0", "frame": "1"} +{"x": 7, "y": 14, "text": "\u2588", "color": "253 on 0", "frame": "1"} +{"x": 8, "y": 14, "text": "\u2588", "color": "252 on 0", "frame": "1"} +{"x": 9, "y": 14, "text": "\u2588", "color": "251 on 0", "frame": "1"} +{"x": 10, "y": 14, "text": "\u2588", "color": "250 on 0", "frame": "1"} +{"x": 11, "y": 14, "text": "\u2588", "color": "249 on 0", "frame": "1"} +{"x": 12, "y": 14, "text": "\u2588", "color": "248 on 0", "frame": "1"} +{"x": 13, "y": 14, "text": "\ud83e\udf5b", "color": "247 on 0", "frame": "1"} +{"x": 18, "y": 14, "text": "\ud83e\udf4a", "color": "242 on 0", "frame": "1"} +{"x": 19, "y": 14, "text": "\ud83e\udf42", "color": "241 on 0", "frame": "1"} +{"x": 20, "y": 14, "text": "\u2588", "color": "240 on 0", "frame": "1"} +{"x": 21, "y": 14, "text": "\ud83e\udf4d", "color": "239 on 0", "frame": "1"} +{"x": 22, "y": 14, "text": "\ud83e\udf3f", "color": "238 on 0", "frame": "1"} +{"x": 38, "y": 14, "text": "\u25e2", "color": "203 on 0", "frame": "1"} +{"x": 39, "y": 14, "text": "\u2588", "color": "202 on 0", "frame": "1"} +{"x": 40, "y": 14, "text": "\u2588", "color": "202 on 0", "frame": "1"} +{"x": 3, "y": 15, "text": "\u25e5", "color": "255 on 0", "frame": "1"} +{"x": 4, "y": 15, "text": "\u2588", "color": "255 on 0", "frame": "1"} +{"x": 5, "y": 15, "text": "\u2588", "color": "255 on 0", "frame": "1"} +{"x": 6, "y": 15, "text": "\u2588", "color": "254 on 0", "frame": "1"} +{"x": 7, "y": 15, "text": "\u2588", "color": "253 on 0", "frame": "1"} +{"x": 8, "y": 15, "text": "\u2588", "color": "252 on 0", "frame": "1"} +{"x": 9, "y": 15, "text": "\u2588", "color": "251 on 0", "frame": "1"} +{"x": 10, "y": 15, "text": "\u2588", "color": "250 on 0", "frame": "1"} +{"x": 11, "y": 15, "text": "\u2588", "color": "249 on 0", "frame": "1"} +{"x": 12, "y": 15, "text": "\u2588", "color": "248 on 0", "frame": "1"} +{"x": 18, "y": 15, "text": "\u2588", "color": "242 on 0", "frame": "1"} +{"x": 19, "y": 15, "text": "\u2588", "color": "241 on 0", "frame": "1"} +{"x": 20, "y": 15, "text": "\u2588", "color": "240 on 0", "frame": "1"} +{"x": 21, "y": 15, "text": "\u2588", "color": "239 on 0", "frame": "1"} +{"x": 22, "y": 15, "text": "\u2588", "color": "238 on 0", "frame": "1"} +{"x": 37, "y": 15, "text": "\u25e2", "color": "203 on 0", "frame": "1"} +{"x": 38, "y": 15, "text": "\u2588", "color": "203 on 0", "frame": "1"} +{"x": 39, "y": 15, "text": "\u2588", "color": "202 on 0", "frame": "1"} +{"x": 40, "y": 15, "text": "\u2588", "color": "202 on 0", "frame": "1"} +{"x": 4, "y": 16, "text": "\ud83e\udf65", "color": "255 on 0", "frame": "1"} +{"x": 5, "y": 16, "text": "\ud83e\udf52", "color": "255 on 0", "frame": "1"} +{"x": 6, "y": 16, "text": "\u2588", "color": "254 on 0", "frame": "1"} +{"x": 7, "y": 16, "text": "\u2588", "color": "253 on 0", "frame": "1"} +{"x": 8, "y": 16, "text": "\u2588", "color": "252 on 0", "frame": "1"} +{"x": 9, "y": 16, "text": "\u2588", "color": "251 on 0", "frame": "1"} +{"x": 10, "y": 16, "text": "\u2588", "color": "250 on 0", "frame": "1"} +{"x": 11, "y": 16, "text": "\u2588", "color": "249 on 0", "frame": "1"} +{"x": 12, "y": 16, "text": "\u2588", "color": "248 on 0", "frame": "1"} +{"x": 13, "y": 16, "text": "\ud83e\udf40", "color": "247 on 0", "frame": "1"} +{"x": 18, "y": 16, "text": "\ud83e\udf65", "color": "242 on 0", "frame": "1"} +{"x": 19, "y": 16, "text": "\ud83e\udf53", "color": "241 on 0", "frame": "1"} +{"x": 20, "y": 16, "text": "\u2588", "color": "240 on 0", "frame": "1"} +{"x": 21, "y": 16, "text": "\ud83e\udf5e", "color": "239 on 0", "frame": "1"} +{"x": 22, "y": 16, "text": "\ud83e\udf5a", "color": "238 on 0", "frame": "1"} +{"x": 35, "y": 16, "text": "\ud83e\udf47", "color": "204 on 0", "frame": "1"} +{"x": 36, "y": 16, "text": "\ud83e\udf44", "color": "204 on 0", "frame": "1"} +{"x": 37, "y": 16, "text": "\u2588", "color": "203 on 0", "frame": "1"} +{"x": 38, "y": 16, "text": "\u2588", "color": "203 on 0", "frame": "1"} +{"x": 39, "y": 16, "text": "\u2588", "color": "202 on 0", "frame": "1"} +{"x": 40, "y": 16, "text": "\u2588", "color": "202 on 0", "frame": "1"} +{"x": 5, "y": 17, "text": "\ud83e\udf62", "color": "252 on 0", "frame": "1"} +{"x": 6, "y": 17, "text": "\ud83e\udf55", "color": "254 on 0", "frame": "1"} +{"x": 7, "y": 17, "text": "\u2588", "color": "253 on 0", "frame": "1"} +{"x": 8, "y": 17, "text": "\u2588", "color": "252 on 0", "frame": "1"} +{"x": 9, "y": 17, "text": "\u2588", "color": "251 on 0", "frame": "1"} +{"x": 10, "y": 17, "text": "\u2588", "color": "250 on 0", "frame": "1"} +{"x": 11, "y": 17, "text": "\u2588", "color": "249 on 0", "frame": "1"} +{"x": 12, "y": 17, "text": "\u2588", "color": "248 on 0", "frame": "1"} +{"x": 13, "y": 17, "text": "\ud83e\udf4e", "color": "247 on 0", "frame": "1"} +{"x": 14, "y": 17, "text": "\ud83e\udf3c", "color": "246 on 0", "frame": "1"} +{"x": 34, "y": 17, "text": "\ud83e\udf4a", "color": "205 on 0", "frame": "1"} +{"x": 35, "y": 17, "text": "\ud83e\udf41", "color": "204 on 0", "frame": "1"} +{"x": 36, "y": 17, "text": "\u2588", "color": "204 on 0", "frame": "1"} +{"x": 37, "y": 17, "text": "\u2588", "color": "203 on 0", "frame": "1"} +{"x": 38, "y": 17, "text": "\u2588", "color": "203 on 0", "frame": "1"} +{"x": 39, "y": 17, "text": "\u2588", "color": "202 on 0", "frame": "1"} +{"x": 40, "y": 17, "text": "\u2588", "color": "202 on 0", "frame": "1"} +{"x": 7, "y": 18, "text": "\ud83e\udf63", "color": "253 on 0", "frame": "1"} +{"x": 8, "y": 18, "text": "\ud83e\udf67", "color": "252 on 0", "frame": "1"} +{"x": 9, "y": 18, "text": "\ud83e\udf53", "color": "251 on 0", "frame": "1"} +{"x": 10, "y": 18, "text": "\u2588", "color": "250 on 0", "frame": "1"} +{"x": 11, "y": 18, "text": "\u2588", "color": "249 on 0", "frame": "1"} +{"x": 12, "y": 18, "text": "\u2588", "color": "248 on 0", "frame": "1"} +{"x": 13, "y": 18, "text": "\u2588", "color": "247 on 0", "frame": "1"} +{"x": 14, "y": 18, "text": "\ud83e\udf4c", "color": "246 on 0", "frame": "1"} +{"x": 15, "y": 18, "text": "\ud83e\udf3f", "color": "245 on 0", "frame": "1"} +{"x": 31, "y": 18, "text": "\ud83e\udf48", "color": "206 on 0", "frame": "1"} +{"x": 32, "y": 18, "text": "\ud83e\udf46", "color": "206 on 0", "frame": "1"} +{"x": 33, "y": 18, "text": "\ud83e\udf42", "color": "205 on 0", "frame": "1"} +{"x": 34, "y": 18, "text": "\u2588", "color": "205 on 0", "frame": "1"} +{"x": 35, "y": 18, "text": "\u2588", "color": "204 on 0", "frame": "1"} +{"x": 36, "y": 18, "text": "\u2588", "color": "204 on 0", "frame": "1"} +{"x": 37, "y": 18, "text": "\u2588", "color": "203 on 0", "frame": "1"} +{"x": 38, "y": 18, "text": "\u2588", "color": "203 on 0", "frame": "1"} +{"x": 39, "y": 18, "text": "\u2588", "color": "202 on 0", "frame": "1"} +{"x": 40, "y": 18, "text": "\u2588", "color": "202 on 0", "frame": "1"} +{"x": 10, "y": 19, "text": "\ud83e\udf63", "color": "250 on 0", "frame": "1"} +{"x": 11, "y": 19, "text": "\ud83e\udf67", "color": "249 on 0", "frame": "1"} +{"x": 12, "y": 19, "text": "\ud83e\udf85", "color": "247 on 0", "frame": "1"} +{"x": 13, "y": 19, "text": "\u2588", "color": "247 on 0", "frame": "1"} +{"x": 14, "y": 19, "text": "\u2588", "color": "246 on 0", "frame": "1"} +{"x": 15, "y": 19, "text": "\u2588", "color": "245 on 0", "frame": "1"} +{"x": 16, "y": 19, "text": "\ud83e\udf4d", "color": "244 on 0", "frame": "1"} +{"x": 17, "y": 19, "text": "\ud83e\udf51", "color": "243 on 0", "frame": "1"} +{"x": 18, "y": 19, "text": "\u2582", "color": "242 on 0", "frame": "1"} +{"x": 19, "y": 19, "text": "\u2581", "color": "241 on 0", "frame": "1"} +{"x": 27, "y": 19, "text": "\u2581", "color": "233 on 0", "frame": "1"} +{"x": 28, "y": 19, "text": "\u2582", "color": "171 on 0", "frame": "1"} +{"x": 29, "y": 19, "text": "\ud83e\udf46", "color": "207 on 0", "frame": "1"} +{"x": 30, "y": 19, "text": "\ud83e\udf42", "color": "207 on 0", "frame": "1"} +{"x": 31, "y": 19, "text": "\u2588", "color": "206 on 0", "frame": "1"} +{"x": 32, "y": 19, "text": "\u2588", "color": "206 on 0", "frame": "1"} +{"x": 33, "y": 19, "text": "\u2588", "color": "205 on 0", "frame": "1"} +{"x": 34, "y": 19, "text": "\u2588", "color": "205 on 0", "frame": "1"} +{"x": 35, "y": 19, "text": "\u2588", "color": "204 on 0", "frame": "1"} +{"x": 36, "y": 19, "text": "\u2588", "color": "204 on 0", "frame": "1"} +{"x": 37, "y": 19, "text": "\u2588", "color": "203 on 0", "frame": "1"} +{"x": 38, "y": 19, "text": "\u2588", "color": "203 on 0", "frame": "1"} +{"x": 39, "y": 19, "text": "\u2588", "color": "202 on 0", "frame": "1"} +{"x": 40, "y": 19, "text": "\u2588", "color": "202 on 0", "frame": "1"} +{"x": 14, "y": 20, "text": "\u2594", "color": "246 on 0", "frame": "1"} +{"x": 15, "y": 20, "text": "\ud83e\udf82", "color": "245 on 0", "frame": "1"} +{"x": 16, "y": 20, "text": "\ud83e\udf83", "color": "244 on 0", "frame": "1"} +{"x": 17, "y": 20, "text": "\ud83e\udf83", "color": "243 on 0", "frame": "1"} +{"x": 18, "y": 20, "text": "\u2580", "color": "242 on 0", "frame": "1"} +{"x": 19, "y": 20, "text": "\u2580", "color": "241 on 0", "frame": "1"} +{"x": 20, "y": 20, "text": "\u2580", "color": "240 on 0", "frame": "1"} +{"x": 21, "y": 20, "text": "\u2580", "color": "239 on 0", "frame": "1"} +{"x": 22, "y": 20, "text": "\u2580", "color": "238 on 0", "frame": "1"} +{"x": 23, "y": 20, "text": "\ud83e\udf83", "color": "237 on 0", "frame": "1"} +{"x": 24, "y": 20, "text": "\ud83e\udf83", "color": "236 on 0", "frame": "1"} +{"x": 25, "y": 20, "text": "\ud83e\udf82", "color": "235 on 0", "frame": "1"} +{"x": 26, "y": 20, "text": "\u2594", "color": "234 on 0", "frame": "1"} +{"x": 28, "y": 20, "text": "\u25e5", "color": "171 on 0", "frame": "1"} +{"x": 29, "y": 20, "text": "\u2588", "color": "207 on 0", "frame": "1"} +{"x": 30, "y": 20, "text": "\u2588", "color": "207 on 0", "frame": "1"} +{"x": 31, "y": 20, "text": "\u2588", "color": "206 on 0", "frame": "1"} +{"x": 32, "y": 20, "text": "\u2588", "color": "206 on 0", "frame": "1"} +{"x": 33, "y": 20, "text": "\u2588", "color": "205 on 0", "frame": "1"} +{"x": 34, "y": 20, "text": "\u2588", "color": "205 on 0", "frame": "1"} +{"x": 35, "y": 20, "text": "\u2588", "color": "204 on 0", "frame": "1"} +{"x": 36, "y": 20, "text": "\u2588", "color": "204 on 0", "frame": "1"} +{"x": 37, "y": 20, "text": "\u2588", "color": "203 on 0", "frame": "1"} +{"x": 38, "y": 20, "text": "\u2588", "color": "203 on 0", "frame": "1"} +{"x": 39, "y": 20, "text": "\u2588", "color": "202 on 0", "frame": "1"} +{"x": 40, "y": 20, "text": "\u2588", "color": "202 on 0", "frame": "1"} From 119cb6e923ee21ea22be5d58a5fb5ec173f4b9c6 Mon Sep 17 00:00:00 2001 From: AdrianGroty <40133867+AdrianGroty@users.noreply.github.com> Date: Mon, 9 Feb 2026 08:25:54 -0600 Subject: [PATCH 02/11] implement ansi loader load .ans files add vd options for # columns, ice colors, and text encoding --- darkdraw/__init__.py | 1 + darkdraw/ansi2ddw.py | 358 +++++++++++++++++++++++++++++++++++++++++++ darkdraw/load_ans.py | 54 +++++++ 3 files changed, 413 insertions(+) create mode 100644 darkdraw/ansi2ddw.py create mode 100644 darkdraw/load_ans.py diff --git a/darkdraw/__init__.py b/darkdraw/__init__.py index a786a4b..033297e 100644 --- a/darkdraw/__init__.py +++ b/darkdraw/__init__.py @@ -6,6 +6,7 @@ from .ansihtml import * # save to .ansihtml from .save import * +from .load_ans import * from .save_ans import * from .load_dur import * from .boxdraw import * diff --git a/darkdraw/ansi2ddw.py b/darkdraw/ansi2ddw.py new file mode 100644 index 0000000..810b721 --- /dev/null +++ b/darkdraw/ansi2ddw.py @@ -0,0 +1,358 @@ +#!/usr/bin/env python3 +"""Convert ANSI escape code text to DarkDraw (.ddw) format.""" + +import json +import re +import sys +from typing import List, Tuple + + +def rgb_to_256(r: int, g: int, b: int) -> int: + """ + Convert RGB values (0-255) to closest xterm 256-color palette index. + + The 256-color palette consists of: + - 0-15: Standard colors (same as 16-color) + - 16-231: 6x6x6 color cube (216 colors) + - 232-255: Grayscale ramp (24 shades) + """ + # Check if it's grayscale (all components equal or very close) + if abs(r - g) < 10 and abs(g - b) < 10 and abs(r - b) < 10: + # Use grayscale ramp (232-255) + # Map 0-255 to 0-23 (24 grayscale levels) + gray = (r + g + b) // 3 + if gray < 8: + return 16 # Black + elif gray > 247: + return 231 # White + else: + return 232 + ((gray - 8) * 24 // 240) + + # Use 6x6x6 color cube (indices 16-231) + # Each component (R,G,B) is mapped to 0-5 + # Formula: 16 + 36*r + 6*g + b + r_cube = (r * 6) // 256 + g_cube = (g * 6) // 256 + b_cube = (b * 6) // 256 + + return 16 + 36 * r_cube + 6 * g_cube + b_cube + + +def parse_ansi(text: str, wrap_at_80: bool = False) -> List[Tuple[int, int, str, str]]: + """ + Parse ANSI escape codes and return list of (x, y, char, color). + + Args: + text: Input text with ANSI codes + wrap_at_80: If True, wrap lines at 80 columns + + Returns: + List of tuples: (x_position, y_position, character, color_string) + """ + # Store all character elements with their positions and colors + elements = [] + + # Track current position in 2D grid (column, row) + x, y = 0, 0 + + # Track the current color state as we parse + current_fg_color = "" + current_bg_color = "" + current_bold = False + + # Map standard ANSI foreground color codes (30-37 normal, 90-97 bright) to simple color numbers + # These correspond to the 16-color palette: black(0), red(1), green(2), etc. + ansi_to_color = { + 30: "0", 31: "1", 32: "2", 33: "3", # Normal colors: black, red, green, yellow + 34: "4", 35: "5", 36: "6", 37: "7", # blue, magenta, cyan, white + 90: "8", 91: "9", 92: "10", 93: "11", # Bright colors: bright black, red, green, yellow + 94: "12", 95: "13", 96: "14", 97: "15", # bright blue, magenta, cyan, white + } + + # Regex to match various ANSI escape sequences + # ESC [ m (SGR color codes), ESC [ t (PabloDraw RGB codes), + # ESC [ A/B/C/D (cursor movement), etc. + ansi_escape = re.compile(r'\x1b\[([0-9;]*)([A-Za-z])') + + # Find all ANSI escape sequences with their terminators + # This approach handles the text and escape sequences separately + last_pos = 0 + for match in ansi_escape.finditer(text): + # Add text before the escape sequence + text_before = text[last_pos:match.start()] + for char in text_before: + if char == '\n': # Newline moves to next row + y += 1 + x = 0 + elif char == '\r': # Carriage return moves back to start of line + x = 0 + else: # Regular character - store it with current position and color + # Check if wrapping is needed + if wrap_at_80 and x >= 80: + y += 1 + x = 0 + # Combine the color components into a single string + color_components = [] + if current_fg_color: + color_components.append(current_fg_color) + if current_bg_color: + color_components.append("on") + color_components.append(current_bg_color) + if current_bold: + color_components.append("bold") + combined_color = " ".join(color_components) + elements.append((x, y, char, combined_color)) + x += 1 # Move to next column + + # Process the escape sequence based on its type (terminator character) + codes_str, terminator = match.groups() + + # Parse semicolon-separated codes (e.g., "31;1" -> [31, 1]) + codes = [int(c) for c in codes_str.split(';') if c] + + if terminator == 'm': # SGR (Select Graphic Rendition) - color and formatting codes + # Process each code to update current color state + # Some codes require multiple values (like 256-color mode), so use an index + idx = 0 + while idx < len(codes): + code = codes[idx] + + if code == 0: # Code 0 = reset all attributes + current_fg_color = "" + current_bg_color = "" + current_bold = False + idx += 1 + elif code == 1: # Code 1 = bold + current_bold = True + idx += 1 + elif code == 22: # Code 22 = normal intensity (not bold) + current_bold = False + idx += 1 + elif code == 38: # Extended foreground color + # Check if this is 256-color mode (38;5;N) or RGB mode (38;2;R;G;B) + if idx + 1 < len(codes): + if codes[idx + 1] == 5 and idx + 2 < len(codes): + # 256-color mode: ESC[38;5;Nm where N is 0-255 + color_num = codes[idx + 2] + current_fg_color = str(color_num) + idx += 3 # Skip past 38, 5, and the color number + elif codes[idx + 1] == 2 and idx + 4 < len(codes): + # RGB mode: ESC[38;2;R;G;Bm + # DarkDraw doesn't have native RGB, so convert to approximate 256-color + r, g, b = codes[idx + 2], codes[idx + 3], codes[idx + 4] + # Use simplified RGB to 256-color conversion + color_num = rgb_to_256(r, g, b) + current_fg_color = str(color_num) + idx += 5 # Skip past 38, 2, R, G, B + else: + idx += 1 + else: + idx += 1 + elif code == 48: # Extended background color + # Same as foreground but for background + if idx + 1 < len(codes): + if codes[idx + 1] == 5 and idx + 2 < len(codes): + # 256-color mode: ESC[48;5;Nm + color_num = codes[idx + 2] + current_bg_color = str(color_num) + idx += 3 + elif codes[idx + 1] == 2 and idx + 4 < len(codes): + # RGB mode: ESC[48;2;R;G;Bm + r, g, b = codes[idx + 2], codes[idx + 3], codes[idx + 4] + color_num = rgb_to_256(r, g, b) + current_bg_color = str(color_num) + idx += 5 + else: + idx += 1 + else: + idx += 1 + elif code in ansi_to_color: # Standard foreground color code + current_fg_color = ansi_to_color[code] + idx += 1 + elif 40 <= code <= 47: # Background colors (normal) + # Convert to background color string (bg0-bg7) + bg = str(code - 40) + current_bg_color = str(bg) + idx += 1 + elif 100 <= code <= 107: # Background colors (bright) + # Convert to bright background color string (bg8-bg15) + bg = str(code - 100 + 8) + current_bg_color = str(bg) + idx += 1 + else: + # Unknown code - skip it + idx += 1 + elif terminator == 't' and len(codes) >= 4 and codes[0] in [0, 1]: # PabloDraw RGB codes + # Handle PabloDraw RGB codes: \x1b[(0|1);R;G;Bt + # codes[0] is 0 for background or 1 for foreground + # codes[1], codes[2], codes[3] are R, G, B values + r, g, b = codes[1], codes[2], codes[3] + color_num = rgb_to_256(r, g, b) + + if codes[0] == 0: # Background + current_bg_color = str(color_num) + elif codes[0] == 1: # Foreground + current_fg_color = str(color_num) + elif terminator in 'ABCDEFGHf': # Cursor movement commands + # Handle various cursor movement commands + if terminator == 'A': # Cursor Up (CUU) + n = codes[0] if codes else 1 + y = max(0, y - n) + elif terminator == 'B': # Cursor Down (CUD) + n = codes[0] if codes else 1 + y += n + elif terminator == 'C': # Cursor Forward (CUF) + n = codes[0] if codes else 1 + x += n + elif terminator == 'D': # Cursor Backward (CUB) + n = codes[0] if codes else 1 + x = max(0, x - n) + elif terminator in 'Hf': # Cursor Position (CUP) - move to row/column + if len(codes) >= 2: + y = codes[0] - 1 if codes[0] > 0 else 0 # Convert to 0-indexed + x = codes[1] - 1 if codes[1] > 0 else 0 # Convert to 0-indexed + elif len(codes) == 1: + y = codes[0] - 1 if codes[0] > 0 else 0 # Just row specified + else: + y, x = 0, 0 # Default to home position + + # Move position past the entire escape sequence (we don't add escape chars to output) + last_pos = match.end() + + # Add any remaining text after the last escape sequence + remaining_text = text[last_pos:] + for char in remaining_text: + if char == '\n': # Newline moves to next row + y += 1 + x = 0 + elif char == '\r': # Carriage return moves back to start of line + x = 0 + else: # Regular character - store it with current position and color + # Check if wrapping is needed + if wrap_at_80 and x >= 80: + y += 1 + x = 0 + # Combine the color components into a single string + color_components = [] + if current_fg_color: + color_components.append(current_fg_color) + if current_bg_color: + color_components.append("on") + color_components.append(current_bg_color) + if current_bold: + color_components.append("bold") + combined_color = " ".join(color_components) + elements.append((x, y, char, combined_color)) + x += 1 # Move to next column + + return elements + + +def create_ddw_elements(parsed: List[Tuple[int, int, str, str]]) -> List[dict]: + """Convert parsed ANSI elements to DarkDraw format, one object per character.""" + # Transform each (x, y, char, color) tuple into a DarkDraw element dictionary + # Each dictionary represents one character at a specific position with a color + elements = [] + for x, y, char, color in parsed: + # Format the color string to match expected format: "fg on bg bold" + formatted_color = format_color_string(color) + elements.append({ + "x": x, # Horizontal position (column number) + "y": y, # Vertical position (row number) + "text": char, # The actual character to display + "color": formatted_color, # Formatted color string + "tags": [], # Empty list - used by DarkDraw for grouping/organizing + "group": "" # Empty string - used by DarkDraw for hierarchical grouping + }) + return elements + + +def format_color_string(color_str: str) -> str: + """Format the color string to match expected format: 'fg on bg bold'.""" + if not color_str.strip(): + return "" + + parts = color_str.split() + fg_color = "" + bg_color = "" + has_bold = False + + for part in parts: + if part == "bold": + has_bold = True + elif "on" in part: + # This is a "color on bg_color" format + sub_parts = part.split(" on ") + if len(sub_parts) == 2: + fg_color = sub_parts[0] if sub_parts[0] != "on" else fg_color + bg_color = sub_parts[1] + elif part.startswith("on "): + bg_color = part[3:] # Remove "on " prefix + elif part.isdigit(): + # Assume first number is foreground, second might be background + if not fg_color: + fg_color = part + elif not bg_color: + bg_color = part # If there's already a fg, treat as bg + elif part.startswith("on"): + # Handle "onN" format + bg_color = part[2:] # Remove "on" prefix + + # Build the formatted string + result_parts = [] + if fg_color: + result_parts.append(fg_color) + if bg_color: + result_parts.append("on") + result_parts.append(bg_color) + if has_bold: + result_parts.append("bold") + + return " ".join(result_parts) + + +def convert_to_ddw(input_text: str) -> str: + """Convert ANSI text to DarkDraw JSONL format.""" + # Determine if input contains explicit newlines + # Remove ANSI codes before checking for newlines + ansi_escape = re.compile(r'\x1b\[[0-9;]*m') + text_without_ansi = ansi_escape.sub('', input_text) + has_newlines = '\n' in text_without_ansi + + # Step 1: Parse the ANSI escape codes into structured data + # Enable wrapping if no explicit newlines found + parsed = parse_ansi(input_text, wrap_at_80=not has_newlines) + + # Step 2: Convert parsed data into DarkDraw element dictionaries + elements = create_ddw_elements(parsed) + + # Step 3: Serialize to JSONL format (JSON Lines - one JSON object per line) + # DarkDraw uses JSONL where each line is a complete JSON object + # separators=(',', ':') creates compact JSON without extra spaces + return '\n'.join(json.dumps(elem, separators=(',', ':')) for elem in elements) + + +def main(): + if len(sys.argv) > 1: + # Read as bytes, decode from CP437 to UTF-8 + with open(sys.argv[1], 'rb') as f: + input_text = f.read().decode('cp437') + + output_file = sys.argv[2] if len(sys.argv) > 2 else sys.argv[1] + '.ddw' + else: + # Read stdin as bytes, decode from CP437 + input_text = sys.stdin.buffer.read().decode('cp437') + output_file = None + + ddw_output = convert_to_ddw(input_text) + + if output_file: + with open(output_file, 'w', encoding='utf-8') as f: + f.write(ddw_output) + print(f"Converted to {output_file}") + else: + print(ddw_output) + + +if __name__ == '__main__': + main() diff --git a/darkdraw/load_ans.py b/darkdraw/load_ans.py new file mode 100644 index 0000000..b8163ce --- /dev/null +++ b/darkdraw/load_ans.py @@ -0,0 +1,54 @@ +import json +import io +from visidata import VisiData, Path, vd +from . import DrawingSheet +from .ans2ddw import AnsiParser + +# Define global options +vd.option('ans_columns', 80, 'width in characters for ANSI files') +vd.option('ans_icecolors', False, 'enable iCE colors (blinking -> bright backgrounds)') +vd.option('ans_encoding', 'cp437', 'character encoding: cp437/dos, iso8859-1/amiga, or utf-8') + +@VisiData.api +def open_ans(vd, p): + # 1. Read raw bytes from source + data = p.read_bytes() + + # 2. Pull current global options + enc_input = vd.options.ans_encoding.lower() + cols = vd.options.ans_columns + ice = vd.options.ans_icecolors + + # 3. Handle aliases for encoding + # Map 'dos' -> 'cp437' and 'amiga' -> 'iso8859-1' to match AnsiParser logic + if enc_input == 'dos': + enc = 'cp437' + elif enc_input == 'amiga': + enc = 'iso8859-1' + else: + enc = enc_input # Support 'utf-8', 'cp437', 'iso8859-1' directly + + # 4. Initialize parser with the explicit values + parser = AnsiParser( + columns=cols, + icecolors=ice, + encoding=enc + ) + + # 5. Parse the data into AnsiChar objects + chars = parser.parse(data) + + # 6. Convert to rows using the AnsiChar transformation logic + rows = [char.to_ddw_row() for char in chars] + + # 7. Generate JSONL output for DrawingSheet + ddwoutput = '\n'.join(json.dumps(r) for r in rows) + '\n' + + # 8. Return the DrawingSheet via the virtual path mechanism + return DrawingSheet( + p.name, + source=Path( + str(p.with_suffix('.ddw')), + fptext=io.StringIO(ddwoutput) + ) + ).drawing From dd266aad63bb84d21d2d95c1e498e918ca461e02 Mon Sep 17 00:00:00 2001 From: AdrianGroty <40133867+AdrianGroty@users.noreply.github.com> Date: Mon, 9 Feb 2026 08:26:33 -0600 Subject: [PATCH 03/11] Delete darkdraw/ansi2ddw.py --- darkdraw/ansi2ddw.py | 358 ------------------------------------------- 1 file changed, 358 deletions(-) delete mode 100644 darkdraw/ansi2ddw.py diff --git a/darkdraw/ansi2ddw.py b/darkdraw/ansi2ddw.py deleted file mode 100644 index 810b721..0000000 --- a/darkdraw/ansi2ddw.py +++ /dev/null @@ -1,358 +0,0 @@ -#!/usr/bin/env python3 -"""Convert ANSI escape code text to DarkDraw (.ddw) format.""" - -import json -import re -import sys -from typing import List, Tuple - - -def rgb_to_256(r: int, g: int, b: int) -> int: - """ - Convert RGB values (0-255) to closest xterm 256-color palette index. - - The 256-color palette consists of: - - 0-15: Standard colors (same as 16-color) - - 16-231: 6x6x6 color cube (216 colors) - - 232-255: Grayscale ramp (24 shades) - """ - # Check if it's grayscale (all components equal or very close) - if abs(r - g) < 10 and abs(g - b) < 10 and abs(r - b) < 10: - # Use grayscale ramp (232-255) - # Map 0-255 to 0-23 (24 grayscale levels) - gray = (r + g + b) // 3 - if gray < 8: - return 16 # Black - elif gray > 247: - return 231 # White - else: - return 232 + ((gray - 8) * 24 // 240) - - # Use 6x6x6 color cube (indices 16-231) - # Each component (R,G,B) is mapped to 0-5 - # Formula: 16 + 36*r + 6*g + b - r_cube = (r * 6) // 256 - g_cube = (g * 6) // 256 - b_cube = (b * 6) // 256 - - return 16 + 36 * r_cube + 6 * g_cube + b_cube - - -def parse_ansi(text: str, wrap_at_80: bool = False) -> List[Tuple[int, int, str, str]]: - """ - Parse ANSI escape codes and return list of (x, y, char, color). - - Args: - text: Input text with ANSI codes - wrap_at_80: If True, wrap lines at 80 columns - - Returns: - List of tuples: (x_position, y_position, character, color_string) - """ - # Store all character elements with their positions and colors - elements = [] - - # Track current position in 2D grid (column, row) - x, y = 0, 0 - - # Track the current color state as we parse - current_fg_color = "" - current_bg_color = "" - current_bold = False - - # Map standard ANSI foreground color codes (30-37 normal, 90-97 bright) to simple color numbers - # These correspond to the 16-color palette: black(0), red(1), green(2), etc. - ansi_to_color = { - 30: "0", 31: "1", 32: "2", 33: "3", # Normal colors: black, red, green, yellow - 34: "4", 35: "5", 36: "6", 37: "7", # blue, magenta, cyan, white - 90: "8", 91: "9", 92: "10", 93: "11", # Bright colors: bright black, red, green, yellow - 94: "12", 95: "13", 96: "14", 97: "15", # bright blue, magenta, cyan, white - } - - # Regex to match various ANSI escape sequences - # ESC [ m (SGR color codes), ESC [ t (PabloDraw RGB codes), - # ESC [ A/B/C/D (cursor movement), etc. - ansi_escape = re.compile(r'\x1b\[([0-9;]*)([A-Za-z])') - - # Find all ANSI escape sequences with their terminators - # This approach handles the text and escape sequences separately - last_pos = 0 - for match in ansi_escape.finditer(text): - # Add text before the escape sequence - text_before = text[last_pos:match.start()] - for char in text_before: - if char == '\n': # Newline moves to next row - y += 1 - x = 0 - elif char == '\r': # Carriage return moves back to start of line - x = 0 - else: # Regular character - store it with current position and color - # Check if wrapping is needed - if wrap_at_80 and x >= 80: - y += 1 - x = 0 - # Combine the color components into a single string - color_components = [] - if current_fg_color: - color_components.append(current_fg_color) - if current_bg_color: - color_components.append("on") - color_components.append(current_bg_color) - if current_bold: - color_components.append("bold") - combined_color = " ".join(color_components) - elements.append((x, y, char, combined_color)) - x += 1 # Move to next column - - # Process the escape sequence based on its type (terminator character) - codes_str, terminator = match.groups() - - # Parse semicolon-separated codes (e.g., "31;1" -> [31, 1]) - codes = [int(c) for c in codes_str.split(';') if c] - - if terminator == 'm': # SGR (Select Graphic Rendition) - color and formatting codes - # Process each code to update current color state - # Some codes require multiple values (like 256-color mode), so use an index - idx = 0 - while idx < len(codes): - code = codes[idx] - - if code == 0: # Code 0 = reset all attributes - current_fg_color = "" - current_bg_color = "" - current_bold = False - idx += 1 - elif code == 1: # Code 1 = bold - current_bold = True - idx += 1 - elif code == 22: # Code 22 = normal intensity (not bold) - current_bold = False - idx += 1 - elif code == 38: # Extended foreground color - # Check if this is 256-color mode (38;5;N) or RGB mode (38;2;R;G;B) - if idx + 1 < len(codes): - if codes[idx + 1] == 5 and idx + 2 < len(codes): - # 256-color mode: ESC[38;5;Nm where N is 0-255 - color_num = codes[idx + 2] - current_fg_color = str(color_num) - idx += 3 # Skip past 38, 5, and the color number - elif codes[idx + 1] == 2 and idx + 4 < len(codes): - # RGB mode: ESC[38;2;R;G;Bm - # DarkDraw doesn't have native RGB, so convert to approximate 256-color - r, g, b = codes[idx + 2], codes[idx + 3], codes[idx + 4] - # Use simplified RGB to 256-color conversion - color_num = rgb_to_256(r, g, b) - current_fg_color = str(color_num) - idx += 5 # Skip past 38, 2, R, G, B - else: - idx += 1 - else: - idx += 1 - elif code == 48: # Extended background color - # Same as foreground but for background - if idx + 1 < len(codes): - if codes[idx + 1] == 5 and idx + 2 < len(codes): - # 256-color mode: ESC[48;5;Nm - color_num = codes[idx + 2] - current_bg_color = str(color_num) - idx += 3 - elif codes[idx + 1] == 2 and idx + 4 < len(codes): - # RGB mode: ESC[48;2;R;G;Bm - r, g, b = codes[idx + 2], codes[idx + 3], codes[idx + 4] - color_num = rgb_to_256(r, g, b) - current_bg_color = str(color_num) - idx += 5 - else: - idx += 1 - else: - idx += 1 - elif code in ansi_to_color: # Standard foreground color code - current_fg_color = ansi_to_color[code] - idx += 1 - elif 40 <= code <= 47: # Background colors (normal) - # Convert to background color string (bg0-bg7) - bg = str(code - 40) - current_bg_color = str(bg) - idx += 1 - elif 100 <= code <= 107: # Background colors (bright) - # Convert to bright background color string (bg8-bg15) - bg = str(code - 100 + 8) - current_bg_color = str(bg) - idx += 1 - else: - # Unknown code - skip it - idx += 1 - elif terminator == 't' and len(codes) >= 4 and codes[0] in [0, 1]: # PabloDraw RGB codes - # Handle PabloDraw RGB codes: \x1b[(0|1);R;G;Bt - # codes[0] is 0 for background or 1 for foreground - # codes[1], codes[2], codes[3] are R, G, B values - r, g, b = codes[1], codes[2], codes[3] - color_num = rgb_to_256(r, g, b) - - if codes[0] == 0: # Background - current_bg_color = str(color_num) - elif codes[0] == 1: # Foreground - current_fg_color = str(color_num) - elif terminator in 'ABCDEFGHf': # Cursor movement commands - # Handle various cursor movement commands - if terminator == 'A': # Cursor Up (CUU) - n = codes[0] if codes else 1 - y = max(0, y - n) - elif terminator == 'B': # Cursor Down (CUD) - n = codes[0] if codes else 1 - y += n - elif terminator == 'C': # Cursor Forward (CUF) - n = codes[0] if codes else 1 - x += n - elif terminator == 'D': # Cursor Backward (CUB) - n = codes[0] if codes else 1 - x = max(0, x - n) - elif terminator in 'Hf': # Cursor Position (CUP) - move to row/column - if len(codes) >= 2: - y = codes[0] - 1 if codes[0] > 0 else 0 # Convert to 0-indexed - x = codes[1] - 1 if codes[1] > 0 else 0 # Convert to 0-indexed - elif len(codes) == 1: - y = codes[0] - 1 if codes[0] > 0 else 0 # Just row specified - else: - y, x = 0, 0 # Default to home position - - # Move position past the entire escape sequence (we don't add escape chars to output) - last_pos = match.end() - - # Add any remaining text after the last escape sequence - remaining_text = text[last_pos:] - for char in remaining_text: - if char == '\n': # Newline moves to next row - y += 1 - x = 0 - elif char == '\r': # Carriage return moves back to start of line - x = 0 - else: # Regular character - store it with current position and color - # Check if wrapping is needed - if wrap_at_80 and x >= 80: - y += 1 - x = 0 - # Combine the color components into a single string - color_components = [] - if current_fg_color: - color_components.append(current_fg_color) - if current_bg_color: - color_components.append("on") - color_components.append(current_bg_color) - if current_bold: - color_components.append("bold") - combined_color = " ".join(color_components) - elements.append((x, y, char, combined_color)) - x += 1 # Move to next column - - return elements - - -def create_ddw_elements(parsed: List[Tuple[int, int, str, str]]) -> List[dict]: - """Convert parsed ANSI elements to DarkDraw format, one object per character.""" - # Transform each (x, y, char, color) tuple into a DarkDraw element dictionary - # Each dictionary represents one character at a specific position with a color - elements = [] - for x, y, char, color in parsed: - # Format the color string to match expected format: "fg on bg bold" - formatted_color = format_color_string(color) - elements.append({ - "x": x, # Horizontal position (column number) - "y": y, # Vertical position (row number) - "text": char, # The actual character to display - "color": formatted_color, # Formatted color string - "tags": [], # Empty list - used by DarkDraw for grouping/organizing - "group": "" # Empty string - used by DarkDraw for hierarchical grouping - }) - return elements - - -def format_color_string(color_str: str) -> str: - """Format the color string to match expected format: 'fg on bg bold'.""" - if not color_str.strip(): - return "" - - parts = color_str.split() - fg_color = "" - bg_color = "" - has_bold = False - - for part in parts: - if part == "bold": - has_bold = True - elif "on" in part: - # This is a "color on bg_color" format - sub_parts = part.split(" on ") - if len(sub_parts) == 2: - fg_color = sub_parts[0] if sub_parts[0] != "on" else fg_color - bg_color = sub_parts[1] - elif part.startswith("on "): - bg_color = part[3:] # Remove "on " prefix - elif part.isdigit(): - # Assume first number is foreground, second might be background - if not fg_color: - fg_color = part - elif not bg_color: - bg_color = part # If there's already a fg, treat as bg - elif part.startswith("on"): - # Handle "onN" format - bg_color = part[2:] # Remove "on" prefix - - # Build the formatted string - result_parts = [] - if fg_color: - result_parts.append(fg_color) - if bg_color: - result_parts.append("on") - result_parts.append(bg_color) - if has_bold: - result_parts.append("bold") - - return " ".join(result_parts) - - -def convert_to_ddw(input_text: str) -> str: - """Convert ANSI text to DarkDraw JSONL format.""" - # Determine if input contains explicit newlines - # Remove ANSI codes before checking for newlines - ansi_escape = re.compile(r'\x1b\[[0-9;]*m') - text_without_ansi = ansi_escape.sub('', input_text) - has_newlines = '\n' in text_without_ansi - - # Step 1: Parse the ANSI escape codes into structured data - # Enable wrapping if no explicit newlines found - parsed = parse_ansi(input_text, wrap_at_80=not has_newlines) - - # Step 2: Convert parsed data into DarkDraw element dictionaries - elements = create_ddw_elements(parsed) - - # Step 3: Serialize to JSONL format (JSON Lines - one JSON object per line) - # DarkDraw uses JSONL where each line is a complete JSON object - # separators=(',', ':') creates compact JSON without extra spaces - return '\n'.join(json.dumps(elem, separators=(',', ':')) for elem in elements) - - -def main(): - if len(sys.argv) > 1: - # Read as bytes, decode from CP437 to UTF-8 - with open(sys.argv[1], 'rb') as f: - input_text = f.read().decode('cp437') - - output_file = sys.argv[2] if len(sys.argv) > 2 else sys.argv[1] + '.ddw' - else: - # Read stdin as bytes, decode from CP437 - input_text = sys.stdin.buffer.read().decode('cp437') - output_file = None - - ddw_output = convert_to_ddw(input_text) - - if output_file: - with open(output_file, 'w', encoding='utf-8') as f: - f.write(ddw_output) - print(f"Converted to {output_file}") - else: - print(ddw_output) - - -if __name__ == '__main__': - main() From cbb1b3376a62e877af7df57b4893cab928b82d2a Mon Sep 17 00:00:00 2001 From: AdrianGroty <40133867+AdrianGroty@users.noreply.github.com> Date: Mon, 9 Feb 2026 08:27:46 -0600 Subject: [PATCH 04/11] Add files via upload ansi>ddw conversion logic --- darkdraw/ans2ddw.py | 684 ++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 684 insertions(+) create mode 100644 darkdraw/ans2ddw.py diff --git a/darkdraw/ans2ddw.py b/darkdraw/ans2ddw.py new file mode 100644 index 0000000..ce0809e --- /dev/null +++ b/darkdraw/ans2ddw.py @@ -0,0 +1,684 @@ +#!/usr/bin/env python3 +"""Convert ANSI art files (.ans) to DarkDraw format (.ddw).""" + +import sys +import json +from dataclasses import dataclass, field +from typing import List, Optional + +# Control characters +LF = 10 +CR = 13 +TAB = 9 +SUB = 26 +ESC = 27 + +# State machine states +STATE_TEXT = 0 +STATE_SEQUENCE = 1 +STATE_END = 2 + +ANSI_SEQUENCE_MAX_LENGTH = 32 + +# 256-color palette (xterm colors) +# Colors 0-15: standard ANSI colors +# Colors 16-231: 6x6x6 RGB cube +# Colors 232-255: grayscale ramp +def _build_256_color_palette(): + """Build the standard xterm 256-color palette.""" + palette = [] + + # 0-15: Standard ANSI colors + ansi_colors = [ + (0, 0, 0), # 0: black + (128, 0, 0), # 1: red + (0, 128, 0), # 2: green + (128, 128, 0), # 3: yellow + (0, 0, 128), # 4: blue + (128, 0, 128), # 5: magenta + (0, 128, 128), # 6: cyan + (192, 192, 192), # 7: white + (128, 128, 128), # 8: bright black + (255, 0, 0), # 9: bright red + (0, 255, 0), # 10: bright green + (255, 255, 0), # 11: bright yellow + (0, 0, 255), # 12: bright blue + (255, 0, 255), # 13: bright magenta + (0, 255, 255), # 14: bright cyan + (255, 255, 255), # 15: bright white + ] + palette.extend(ansi_colors) + + # 16-231: 6x6x6 RGB cube + for r in range(6): + for g in range(6): + for b in range(6): + palette.append(( + 0 if r == 0 else 55 + r * 40, + 0 if g == 0 else 55 + g * 40, + 0 if b == 0 else 55 + b * 40 + )) + + # 232-255: grayscale ramp + for i in range(24): + gray = 8 + i * 10 + palette.append((gray, gray, gray)) + + return palette + +COLOR_256_PALETTE = _build_256_color_palette() + + +# CP437 (DOS) to Unicode mapping for characters 128-255 +# Characters 0-127 are identical to ASCII +CP437_TO_UNICODE = [ + 0x00C7, 0x00FC, 0x00E9, 0x00E2, 0x00E4, 0x00E0, 0x00E5, 0x00E7, # 128-135 + 0x00EA, 0x00EB, 0x00E8, 0x00EF, 0x00EE, 0x00EC, 0x00C4, 0x00C5, # 136-143 + 0x00C9, 0x00E6, 0x00C6, 0x00F4, 0x00F6, 0x00F2, 0x00FB, 0x00F9, # 144-151 + 0x00FF, 0x00D6, 0x00DC, 0x00A2, 0x00A3, 0x00A5, 0x20A7, 0x0192, # 152-159 + 0x00E1, 0x00ED, 0x00F3, 0x00FA, 0x00F1, 0x00D1, 0x00AA, 0x00BA, # 160-167 + 0x00BF, 0x2310, 0x00AC, 0x00BD, 0x00BC, 0x00A1, 0x00AB, 0x00BB, # 168-175 + 0x2591, 0x2592, 0x2593, 0x2502, 0x2524, 0x2561, 0x2562, 0x2556, # 176-183 + 0x2555, 0x2563, 0x2551, 0x2557, 0x255D, 0x255C, 0x255B, 0x2510, # 184-191 + 0x2514, 0x2534, 0x252C, 0x251C, 0x2500, 0x253C, 0x255E, 0x255F, # 192-199 + 0x255A, 0x2554, 0x2569, 0x2566, 0x2560, 0x2550, 0x256C, 0x2567, # 200-207 + 0x2568, 0x2564, 0x2565, 0x2559, 0x2558, 0x2552, 0x2553, 0x256B, # 208-215 + 0x256A, 0x2518, 0x250C, 0x2588, 0x2584, 0x258C, 0x2590, 0x2580, # 216-223 + 0x03B1, 0x00DF, 0x0393, 0x03C0, 0x03A3, 0x03C3, 0x00B5, 0x03C4, # 224-231 + 0x03A6, 0x0398, 0x03A9, 0x03B4, 0x221E, 0x03C6, 0x03B5, 0x2229, # 232-239 + 0x2261, 0x00B1, 0x2265, 0x2264, 0x2320, 0x2321, 0x00F7, 0x2248, # 240-247 + 0x00B0, 0x2219, 0x00B7, 0x221A, 0x207F, 0x00B2, 0x25A0, 0x00A0, # 248-255 +] + + +def cp437_to_utf8(byte_val: int) -> str: + """Convert CP437 byte value to UTF-8 character.""" + if byte_val < 128: + return chr(byte_val) + else: + return chr(CP437_TO_UNICODE[byte_val - 128]) + + +def iso8859_1_to_utf8(byte_val: int) -> str: + """Convert ISO-8859-1 byte value to UTF-8 character.""" + # ISO-8859-1 maps directly to Unicode code points 0-255 + return chr(byte_val) + + +def rgb_to_256color(rgb: int) -> int: + """Convert 24-bit RGB to nearest xterm 256 color code.""" + r = (rgb >> 16) & 0xFF + g = (rgb >> 8) & 0xFF + b = rgb & 0xFF + + best_match = 0 + best_distance = float('inf') + + for i, (pr, pg, pb) in enumerate(COLOR_256_PALETTE): + # Euclidean distance in RGB space + distance = (r - pr) ** 2 + (g - pg) ** 2 + (b - pb) ** 2 + if distance < best_distance: + best_distance = distance + best_match = i + + return best_match + + +@dataclass +class AnsiChar: + """Character with position and color attributes.""" + column: int + row: int + background: int + foreground: int + character: str + background24: int = 0 # 24-bit RGB background (0xRRGGBB) + foreground24: int = 0 # 24-bit RGB foreground (0xRRGGBB) + bold: bool = False + italic: bool = False + underline: bool = False + blink: bool = False + reverse: bool = False + dim: bool = False + + def to_ddw_row(self, frame_id: Optional[str] = None) -> dict: + """Convert to DarkDraw row format.""" + # Convert colors to 256-color codes + color_parts = [] + + if self.foreground24: + # Convert 24-bit to nearest 256-color + fg_256 = rgb_to_256color(self.foreground24) + color_parts.append(str(fg_256)) + else: + # Use actual ANSI color code (0-15 for standard colors) + color_parts.append(str(self.foreground)) + + if self.background24: + # Convert 24-bit to nearest 256-color + bg_256 = rgb_to_256color(self.background24) + color_parts.append(f"on {bg_256}") + else: + # Use actual ANSI color code (0-15 for standard colors) + color_parts.append(f"on {self.background}") + + # Add text attributes + if self.bold: + color_parts.append("bold") + if self.italic: + color_parts.append("italic") + if self.underline: + color_parts.append("underline") + if self.blink: + color_parts.append("blink") + if self.reverse: + color_parts.append("reverse") + if self.dim: + color_parts.append("dim") + + return { + "type": "", + "x": self.column, + "y": self.row, + "text": self.character, + "color": " ".join(color_parts) if color_parts else "", + "tags": [], + "group": "", + "frame": frame_id or "", + "id": "", + "rows": [] + } + + +class AnsiParser: + """Parse ANSI escape sequences and build character buffer.""" + + def __init__(self, columns: int = 80, icecolors: bool = False, encoding: str = 'cp437'): + self.columns = columns + self.icecolors = icecolors + self.encoding = encoding # 'cp437' or 'iso8859-1' + + # Color state + self.background = 0 + self.foreground = 7 + self.background24 = 0 # 24-bit RGB background + self.foreground24 = 0 # 24-bit RGB foreground + + # Text attributes + self.bold = False + self.blink = False + self.invert = False + self.italic = False + self.underline = False + self.dim = False + + # Cursor position + self.column = 0 + self.row = 0 + self.saved_row = 0 + self.saved_column = 0 + + # Output buffer + self.chars: List[AnsiChar] = [] + self.column_max = 0 + self.row_max = 0 + + def parse(self, data: bytes) -> List[AnsiChar]: + """Parse ANSI data and return character list.""" + state = STATE_TEXT + i = 0 + length = len(data) + + while i < length: + cursor = data[i] + + # Handle column wrapping + if self.column == self.columns: + self.row += 1 + self.column = 0 + + if state == STATE_TEXT: + if cursor == LF: + self.row += 1 + self.column = 0 + elif cursor == CR: + pass + elif cursor == TAB: + self.column += 8 + elif cursor == SUB: + state = STATE_END + elif cursor == ESC: + # Check for CSI sequence (ESC [) + if i + 1 < length and data[i + 1] == 91: # '[' + state = STATE_SEQUENCE + i += 1 + else: + # Record character (convert to UTF-8 based on encoding) + if self.encoding == 'utf-8': + # For UTF-8, we need to decode multi-byte sequences + char_bytes = bytearray([cursor]) + # Determine how many bytes this UTF-8 character needs + if cursor < 0x80: + # Single byte (ASCII) + pass + elif cursor & 0xE0 == 0xC0: + # 2-byte sequence + if i + 1 < length: + i += 1 + char_bytes.append(data[i]) + elif cursor & 0xF0 == 0xE0: + # 3-byte sequence + for _ in range(2): + if i + 1 < length: + i += 1 + char_bytes.append(data[i]) + elif cursor & 0xF8 == 0xF0: + # 4-byte sequence + for _ in range(3): + if i + 1 < length: + i += 1 + char_bytes.append(data[i]) + try: + self._add_char(char_bytes.decode('utf-8')) + except UnicodeDecodeError: + self._add_char('?') + elif self.encoding == 'iso8859-1': + self._add_char(iso8859_1_to_utf8(cursor)) + else: + self._add_char(cp437_to_utf8(cursor)) + + elif state == STATE_SEQUENCE: + # Parse escape sequence + seq_len = self._parse_sequence(data[i:]) + i += seq_len + state = STATE_TEXT + + elif state == STATE_END: + break + + i += 1 + + return self.chars + + def _add_char(self, char: str): + """Add character to buffer with current attributes.""" + # Track max dimensions + if self.column > self.column_max: + self.column_max = self.column + if self.row > self.row_max: + self.row_max = self.row + + # Apply invert/reverse + if self.invert: + bg = self.foreground % 8 + fg = self.background + (self.foreground & 8) + bg24 = 0 + fg24 = 0 + else: + bg = self.background + fg = self.foreground + bg24 = self.background24 + fg24 = self.foreground24 + + self.chars.append(AnsiChar( + column=self.column, + row=self.row, + background=bg, + foreground=fg, + background24=bg24, + foreground24=fg24, + character=char, + bold=self.bold, + italic=self.italic, + underline=self.underline, + blink=self.blink, + reverse=self.invert, + dim=self.dim + )) + + self.column += 1 + + def _parse_sequence(self, data: bytes) -> int: + """Parse CSI sequence and return length consumed.""" + max_len = min(len(data), ANSI_SEQUENCE_MAX_LENGTH) + + for seq_len in range(max_len): + seq_char = chr(data[seq_len]) if seq_len < len(data) else '' + + # Cursor position (H or f) + if seq_char in ('H', 'f'): + self._handle_cursor_position(data[:seq_len]) + return seq_len + + # Cursor up (A) + if seq_char == 'A': + n = self._parse_numeric(data[:seq_len], default=1) + self.row = max(0, self.row - n) + return seq_len + + # Cursor down (B) + if seq_char == 'B': + n = self._parse_numeric(data[:seq_len], default=1) + self.row += n + return seq_len + + # Cursor forward (C) + if seq_char == 'C': + n = self._parse_numeric(data[:seq_len], default=1) + self.column = min(self.columns, self.column + n) + return seq_len + + # Cursor backward (D) + if seq_char == 'D': + n = self._parse_numeric(data[:seq_len], default=1) + self.column = max(0, self.column - n) + return seq_len + + # Save cursor (s) + if seq_char == 's': + self.saved_row = self.row + self.saved_column = self.column + return seq_len + + # Restore cursor (u) + if seq_char == 'u': + self.row = self.saved_row + self.column = self.saved_column + return seq_len + + # Erase display (J) + if seq_char == 'J': + n = self._parse_numeric(data[:seq_len], default=0) + if n == 2: + self.column = 0 + self.row = 0 + self.column_max = 0 + self.row_max = 0 + self.chars.clear() + return seq_len + + # Set graphics mode (m) + if seq_char == 'm': + self._handle_sgr(data[:seq_len]) + return seq_len + + # PabloDraw 24-bit color (t) + if seq_char == 't': + self._handle_pablodraw_color(data[:seq_len]) + return seq_len + + # Skip other sequences + if 64 <= ord(seq_char) <= 126: + return seq_len + + return 0 + + def _handle_cursor_position(self, seq: bytes): + """Handle cursor position escape sequence.""" + seq_str = seq.decode('ascii', errors='ignore') + + if seq_str.startswith(';'): + # ";column" format + parts = seq_str[1:].split(';') + row = 1 + col = int(parts[0]) if parts and parts[0] else 1 + else: + # "row;column" format + parts = seq_str.split(';') + row = int(parts[0]) if parts and parts[0] else 1 + col = int(parts[1]) if len(parts) > 1 and parts[1] else 1 + + self.row = max(0, row - 1) + self.column = max(0, col - 1) + + def _handle_sgr(self, seq: bytes): + """Handle SGR (Select Graphic Rendition) sequence.""" + seq_str = seq.decode('ascii', errors='ignore') + params = [p.strip() for p in seq_str.split(';') if p.strip()] + + if not params: + params = ['0'] + + i = 0 + while i < len(params): + try: + val = int(params[i]) + except ValueError: + i += 1 + continue + + # Reset all attributes + if val == 0: + self.background = 0 + self.background24 = 0 + self.foreground = 7 + self.foreground24 = 0 + self.bold = False + self.blink = False + self.invert = False + self.italic = False + self.underline = False + self.dim = False + + # Bold/bright + elif val == 1: + self.bold = True + self.foreground = (self.foreground % 8) + 8 + self.foreground24 = 0 # Clear 24-bit when using bold + + # Dim + elif val == 2: + self.dim = True + + # Italic + elif val == 3: + self.italic = True + + # Underline + elif val == 4: + self.underline = True + + # Blink + + elif val == 5: + if self.icecolors: + self.background = (self.background % 8) + 8 + self.blink = False + else: + self.blink = True + + + # Invert/reverse + elif val == 7: + self.invert = True + + # Not bold + elif val == 22: + self.bold = False + self.dim = False + if self.foreground >= 8: + self.foreground -= 8 + + # Not italic + elif val == 23: + self.italic = False + + # Not underlined + elif val == 24: + self.underline = False + + # Not blinking + elif val == 25: + self.blink = False + if self.icecolors and self.background >= 8: + self.background -= 8 + + # Not inverted + elif val == 27: + self.invert = False + + # Foreground color (30-37) + elif 30 <= val <= 37: + self.foreground = val - 30 + self.foreground24 = 0 + if self.bold: + self.foreground += 8 + + # Extended foreground color (38) + elif val == 38: + if i + 2 < len(params): + mode = int(params[i + 1]) + if mode == 5: # 256-color mode + self.foreground = int(params[i + 2]) & 0xFF + self.foreground24 = 0 + i += 2 + elif mode == 2 and i + 4 < len(params): # 24-bit RGB mode + r = int(params[i + 2]) & 0xFF + g = int(params[i + 3]) & 0xFF + b = int(params[i + 4]) & 0xFF + self.foreground24 = (r << 16) | (g << 8) | b + i += 4 + + # Background color (40-47) + elif 40 <= val <= 47: + self.background = val - 40 + self.background24 = 0 + if self.blink and self.icecolors: + self.background += 8 + + # Extended background color (48) + elif val == 48: + if i + 2 < len(params): + mode = int(params[i + 1]) + if mode == 5: # 256-color mode + self.background = int(params[i + 2]) & 0xFF + self.background24 = 0 + i += 2 + elif mode == 2 and i + 4 < len(params): # 24-bit RGB mode + r = int(params[i + 2]) & 0xFF + g = int(params[i + 3]) & 0xFF + b = int(params[i + 4]) & 0xFF + self.background24 = (r << 16) | (g << 8) | b + i += 4 + + # Bright foreground colors (90-97) + elif 90 <= val <= 97: + self.foreground = val - 90 + 8 + self.foreground24 = 0 + + # Bright background colors (100-107) + elif 100 <= val <= 107: + self.background = val - 100 + 8 + self.background24 = 0 + + i += 1 + + def _handle_pablodraw_color(self, seq: bytes): + """Handle PabloDraw 24-bit ANSI color sequences (CSI...t).""" + seq_str = seq.decode('ascii', errors='ignore') + params = [p.strip() for p in seq_str.split(';') if p.strip()] + + if not params: + return + + try: + # First param: 0=background, 1=foreground + color_type = int(params[0]) + + # Next 3 params: R, G, B values (0-255) + r = int(params[1]) & 0xFF if len(params) > 1 else 0 + g = int(params[2]) & 0xFF if len(params) > 2 else 0 + b = int(params[3]) & 0xFF if len(params) > 3 else 0 + + # Combine into 24-bit RGB value + rgb = (r << 16) | (g << 8) | b + + if color_type == 0: + self.background24 = rgb + elif color_type == 1: + self.foreground24 = rgb + except (ValueError, IndexError): + pass + + def _parse_numeric(self, seq: bytes, default: int = 0) -> int: + """Parse numeric value from sequence.""" + seq_str = seq.decode('ascii', errors='ignore').strip() + if not seq_str: + return default + try: + return int(seq_str) + except ValueError: + return default + + +def ans_to_ddw(input_path: str, output_path: str, columns: int = 80, + icecolors: bool = False, encoding: str = 'cp437'): + """Convert ANSI file to DarkDraw format. + + Args: + input_path: Path to input .ans file + output_path: Path to output .ddw file + columns: Width in characters (default: 80) + icecolors: Enable iCE colors (blinking -> bright backgrounds) + encoding: Character encoding - 'cp437' (PC/DOS), 'iso8859-1' (Amiga), or 'utf-8' (default: 'cp437') + """ + # Read ANSI file + with open(input_path, 'rb') as f: + data = f.read() + + # Parse ANSI + parser = AnsiParser(columns=columns, icecolors=icecolors, encoding=encoding) + chars = parser.parse(data) + + # Convert to DarkDraw rows + rows = [char.to_ddw_row() for char in chars] + + # Write as JSONL + with open(output_path, 'w', encoding='utf-8') as f: + for row in rows: + f.write(json.dumps(row) + '\n') + + print(f"Converted {len(rows)} characters from {input_path} to {output_path}") + print(f"Dimensions: {parser.column_max + 1} x {parser.row_max + 1}") + print(f"Encoding: {encoding}") + + +def main(): + if len(sys.argv) < 3: + print("Usage: ans2ddw.py [columns] [options]") + print(" columns: width in characters (default: 80)") + print() + print("Options:") + print(" --icecolors: enable iCE colors (blinking -> bright backgrounds)") + print(" --amiga: use ISO-8859-1 encoding (Amiga ANSI)") + print(" --pc: use CP437 encoding (PC/DOS ANSI, default)") + print(" --utf8: use UTF-8 encoding (modern ANSI)") + sys.exit(1) + + input_path = sys.argv[1] + output_path = sys.argv[2] + columns = 80 + icecolors = False + encoding = 'cp437' + + if len(sys.argv) > 3: + try: + columns = int(sys.argv[3]) + except ValueError: + pass + + if '--icecolors' in sys.argv: + icecolors = True + + if '--amiga' in sys.argv: + encoding = 'iso8859-1' + elif '--utf8' in sys.argv: + encoding = 'utf-8' + elif '--pc' in sys.argv: + encoding = 'cp437' + + ans_to_ddw(input_path, output_path, columns=columns, icecolors=icecolors, encoding=encoding) + + +if __name__ == '__main__': + main() From e721f97491ee80d7f94644cc710a3cd7e0ab366f Mon Sep 17 00:00:00 2001 From: AdrianGroty <40133867+AdrianGroty@users.noreply.github.com> Date: Wed, 11 Feb 2026 14:23:14 -0600 Subject: [PATCH 05/11] include SAUCE record include SAUCE record at top of backing sheet --- darkdraw/ans2ddw.py | 390 +++++++++++++++++++++++-------------------- darkdraw/load_ans.py | 14 +- 2 files changed, 218 insertions(+), 186 deletions(-) diff --git a/darkdraw/ans2ddw.py b/darkdraw/ans2ddw.py index ce0809e..70f21a1 100644 --- a/darkdraw/ans2ddw.py +++ b/darkdraw/ans2ddw.py @@ -4,7 +4,7 @@ import sys import json from dataclasses import dataclass, field -from typing import List, Optional +from typing import List, Optional, Tuple # Control characters LF = 10 @@ -21,31 +21,16 @@ ANSI_SEQUENCE_MAX_LENGTH = 32 # 256-color palette (xterm colors) -# Colors 0-15: standard ANSI colors -# Colors 16-231: 6x6x6 RGB cube -# Colors 232-255: grayscale ramp def _build_256_color_palette(): """Build the standard xterm 256-color palette.""" palette = [] # 0-15: Standard ANSI colors ansi_colors = [ - (0, 0, 0), # 0: black - (128, 0, 0), # 1: red - (0, 128, 0), # 2: green - (128, 128, 0), # 3: yellow - (0, 0, 128), # 4: blue - (128, 0, 128), # 5: magenta - (0, 128, 128), # 6: cyan - (192, 192, 192), # 7: white - (128, 128, 128), # 8: bright black - (255, 0, 0), # 9: bright red - (0, 255, 0), # 10: bright green - (255, 255, 0), # 11: bright yellow - (0, 0, 255), # 12: bright blue - (255, 0, 255), # 13: bright magenta - (0, 255, 255), # 14: bright cyan - (255, 255, 255), # 15: bright white + (0, 0, 0), (128, 0, 0), (0, 128, 0), (128, 128, 0), + (0, 0, 128), (128, 0, 128), (0, 128, 128), (192, 192, 192), + (128, 128, 128), (255, 0, 0), (0, 255, 0), (255, 255, 0), + (0, 0, 255), (255, 0, 255), (0, 255, 255), (255, 255, 255), ] palette.extend(ansi_colors) @@ -68,29 +53,26 @@ def _build_256_color_palette(): COLOR_256_PALETTE = _build_256_color_palette() - # CP437 (DOS) to Unicode mapping for characters 128-255 -# Characters 0-127 are identical to ASCII CP437_TO_UNICODE = [ - 0x00C7, 0x00FC, 0x00E9, 0x00E2, 0x00E4, 0x00E0, 0x00E5, 0x00E7, # 128-135 - 0x00EA, 0x00EB, 0x00E8, 0x00EF, 0x00EE, 0x00EC, 0x00C4, 0x00C5, # 136-143 - 0x00C9, 0x00E6, 0x00C6, 0x00F4, 0x00F6, 0x00F2, 0x00FB, 0x00F9, # 144-151 - 0x00FF, 0x00D6, 0x00DC, 0x00A2, 0x00A3, 0x00A5, 0x20A7, 0x0192, # 152-159 - 0x00E1, 0x00ED, 0x00F3, 0x00FA, 0x00F1, 0x00D1, 0x00AA, 0x00BA, # 160-167 - 0x00BF, 0x2310, 0x00AC, 0x00BD, 0x00BC, 0x00A1, 0x00AB, 0x00BB, # 168-175 - 0x2591, 0x2592, 0x2593, 0x2502, 0x2524, 0x2561, 0x2562, 0x2556, # 176-183 - 0x2555, 0x2563, 0x2551, 0x2557, 0x255D, 0x255C, 0x255B, 0x2510, # 184-191 - 0x2514, 0x2534, 0x252C, 0x251C, 0x2500, 0x253C, 0x255E, 0x255F, # 192-199 - 0x255A, 0x2554, 0x2569, 0x2566, 0x2560, 0x2550, 0x256C, 0x2567, # 200-207 - 0x2568, 0x2564, 0x2565, 0x2559, 0x2558, 0x2552, 0x2553, 0x256B, # 208-215 - 0x256A, 0x2518, 0x250C, 0x2588, 0x2584, 0x258C, 0x2590, 0x2580, # 216-223 - 0x03B1, 0x00DF, 0x0393, 0x03C0, 0x03A3, 0x03C3, 0x00B5, 0x03C4, # 224-231 - 0x03A6, 0x0398, 0x03A9, 0x03B4, 0x221E, 0x03C6, 0x03B5, 0x2229, # 232-239 - 0x2261, 0x00B1, 0x2265, 0x2264, 0x2320, 0x2321, 0x00F7, 0x2248, # 240-247 - 0x00B0, 0x2219, 0x00B7, 0x221A, 0x207F, 0x00B2, 0x25A0, 0x00A0, # 248-255 + 0x00C7, 0x00FC, 0x00E9, 0x00E2, 0x00E4, 0x00E0, 0x00E5, 0x00E7, + 0x00EA, 0x00EB, 0x00E8, 0x00EF, 0x00EE, 0x00EC, 0x00C4, 0x00C5, + 0x00C9, 0x00E6, 0x00C6, 0x00F4, 0x00F6, 0x00F2, 0x00FB, 0x00F9, + 0x00FF, 0x00D6, 0x00DC, 0x00A2, 0x00A3, 0x00A5, 0x20A7, 0x0192, + 0x00E1, 0x00ED, 0x00F3, 0x00FA, 0x00F1, 0x00D1, 0x00AA, 0x00BA, + 0x00BF, 0x2310, 0x00AC, 0x00BD, 0x00BC, 0x00A1, 0x00AB, 0x00BB, + 0x2591, 0x2592, 0x2593, 0x2502, 0x2524, 0x2561, 0x2562, 0x2556, + 0x2555, 0x2563, 0x2551, 0x2557, 0x255D, 0x255C, 0x255B, 0x2510, + 0x2514, 0x2534, 0x252C, 0x251C, 0x2500, 0x253C, 0x255E, 0x255F, + 0x255A, 0x2554, 0x2569, 0x2566, 0x2560, 0x2550, 0x256C, 0x2567, + 0x2568, 0x2564, 0x2565, 0x2559, 0x2558, 0x2552, 0x2553, 0x256B, + 0x256A, 0x2518, 0x250C, 0x2588, 0x2584, 0x258C, 0x2590, 0x2580, + 0x03B1, 0x00DF, 0x0393, 0x03C0, 0x03A3, 0x03C3, 0x00B5, 0x03C4, + 0x03A6, 0x0398, 0x03A9, 0x03B4, 0x221E, 0x03C6, 0x03B5, 0x2229, + 0x2261, 0x00B1, 0x2265, 0x2264, 0x2320, 0x2321, 0x00F7, 0x2248, + 0x00B0, 0x2219, 0x00B7, 0x221A, 0x207F, 0x00B2, 0x25A0, 0x00A0, ] - def cp437_to_utf8(byte_val: int) -> str: """Convert CP437 byte value to UTF-8 character.""" if byte_val < 128: @@ -98,13 +80,10 @@ def cp437_to_utf8(byte_val: int) -> str: else: return chr(CP437_TO_UNICODE[byte_val - 128]) - def iso8859_1_to_utf8(byte_val: int) -> str: """Convert ISO-8859-1 byte value to UTF-8 character.""" - # ISO-8859-1 maps directly to Unicode code points 0-255 return chr(byte_val) - def rgb_to_256color(rgb: int) -> int: """Convert 24-bit RGB to nearest xterm 256 color code.""" r = (rgb >> 16) & 0xFF @@ -115,7 +94,6 @@ def rgb_to_256color(rgb: int) -> int: best_distance = float('inf') for i, (pr, pg, pb) in enumerate(COLOR_256_PALETTE): - # Euclidean distance in RGB space distance = (r - pr) ** 2 + (g - pg) ** 2 + (b - pb) ** 2 if distance < best_distance: best_distance = distance @@ -123,6 +101,103 @@ def rgb_to_256color(rgb: int) -> int: return best_match +@dataclass +class SauceRecord: + """SAUCE metadata record.""" + title: str = "" + author: str = "" + group: str = "" + date: str = "" + file_size: int = 0 + data_type: int = 0 + file_type: int = 0 + t_info1: int = 0 + t_info2: int = 0 + t_info3: int = 0 + t_info4: int = 0 + comments: List[str] = field(default_factory=list) + t_flags: int = 0 + t_info_s: str = "" + + def to_ddw_rows(self) -> List[dict]: + """Convert SAUCE record to DarkDraw text rows.""" + rows = [] + y = 0 + + if self.title: + rows.append({ + "type": "", "x": 0, "y": y, "text": self.title, + "color": "", "tags": [], "group": "", + "frame": "SAUCE record", "id": "Title", "rows": [] + }) + y += 1 + + if self.author: + rows.append({ + "type": "", "x": 0, "y": y, "text": self.author, + "color": "", "tags": [], "group": "", + "frame": "SAUCE record", "id": "Author", "rows": [] + }) + y += 1 + + if self.group: + rows.append({ + "type": "", "x": 0, "y": y, "text": self.group, + "color": "", "tags": [], "group": "", + "frame": "SAUCE record", "id": "Group", "rows": [] + }) + y += 1 + + if self.date: + rows.append({ + "type": "", "x": 0, "y": y, "text": self.date, + "color": "", "tags": [], "group": "", + "frame": "SAUCE record", "id": "Date", "rows": [] + }) + y += 1 + + if self.t_info1 or self.t_info2: + rows.append({ + "type": "", "x": 0, "y": y, "text": f"{self.t_info1}x{self.t_info2}", + "color": "", "tags": [], "group": "", + "frame": "SAUCE record", "id": "Dimensions", "rows": [] + }) + y += 1 + + if self.t_flags: + flags = [] + if self.t_flags & 0x01: + flags.append("non-blink") + if self.t_flags & 0x02: + flags.append("letter-spacing") + if self.t_flags & 0x04: + flags.append("aspect-ratio") + + rows.append({ + "type": "", "x": 0, "y": y, + "text": ", ".join(flags) if flags else str(self.t_flags), + "color": "", "tags": [], "group": "", + "frame": "SAUCE record", "id": "Flags", "rows": [] + }) + y += 1 + + if self.t_info_s: + rows.append({ + "type": "", "x": 0, "y": y, "text": self.t_info_s, + "color": "", "tags": [], "group": "", + "frame": "SAUCE record", "id": "Font", "rows": [] + }) + y += 1 + + for i, comment in enumerate(self.comments): + rows.append({ + "type": "", "x": 0, "y": y, "text": comment, + "color": "", "tags": [], "group": "", + "frame": "SAUCE record", "id": f"Comment {i+1}", "rows": [] + }) + y += 1 + + return rows @dataclass class AnsiChar: @@ -132,8 +207,8 @@ class AnsiChar: background: int foreground: int character: str - background24: int = 0 # 24-bit RGB background (0xRRGGBB) - foreground24: int = 0 # 24-bit RGB foreground (0xRRGGBB) + background24: int = 0 + foreground24: int = 0 bold: bool = False italic: bool = False underline: bool = False @@ -143,26 +218,20 @@ class AnsiChar: def to_ddw_row(self, frame_id: Optional[str] = None) -> dict: """Convert to DarkDraw row format.""" - # Convert colors to 256-color codes color_parts = [] if self.foreground24: - # Convert 24-bit to nearest 256-color fg_256 = rgb_to_256color(self.foreground24) color_parts.append(str(fg_256)) else: - # Use actual ANSI color code (0-15 for standard colors) color_parts.append(str(self.foreground)) if self.background24: - # Convert 24-bit to nearest 256-color bg_256 = rgb_to_256color(self.background24) color_parts.append(f"on {bg_256}") else: - # Use actual ANSI color code (0-15 for standard colors) color_parts.append(f"on {self.background}") - # Add text attributes if self.bold: color_parts.append("bold") if self.italic: @@ -177,34 +246,24 @@ def to_ddw_row(self, frame_id: Optional[str] = None) -> dict: color_parts.append("dim") return { - "type": "", - "x": self.column, - "y": self.row, - "text": self.character, - "color": " ".join(color_parts) if color_parts else "", - "tags": [], - "group": "", - "frame": frame_id or "", - "id": "", - "rows": [] + "type": "", "x": self.column, "y": self.row, + "text": self.character, "color": " ".join(color_parts) if color_parts else "", + "tags": [], "group": "", "frame": frame_id or "", "id": "", "rows": [] } - class AnsiParser: """Parse ANSI escape sequences and build character buffer.""" def __init__(self, columns: int = 80, icecolors: bool = False, encoding: str = 'cp437'): self.columns = columns self.icecolors = icecolors - self.encoding = encoding # 'cp437' or 'iso8859-1' + self.encoding = encoding - # Color state self.background = 0 self.foreground = 7 - self.background24 = 0 # 24-bit RGB background - self.foreground24 = 0 # 24-bit RGB foreground + self.background24 = 0 + self.foreground24 = 0 - # Text attributes self.bold = False self.blink = False self.invert = False @@ -212,13 +271,11 @@ def __init__(self, columns: int = 80, icecolors: bool = False, encoding: str = ' self.underline = False self.dim = False - # Cursor position self.column = 0 self.row = 0 self.saved_row = 0 self.saved_column = 0 - # Output buffer self.chars: List[AnsiChar] = [] self.column_max = 0 self.row_max = 0 @@ -232,7 +289,6 @@ def parse(self, data: bytes) -> List[AnsiChar]: while i < length: cursor = data[i] - # Handle column wrapping if self.column == self.columns: self.row += 1 self.column = 0 @@ -248,32 +304,24 @@ def parse(self, data: bytes) -> List[AnsiChar]: elif cursor == SUB: state = STATE_END elif cursor == ESC: - # Check for CSI sequence (ESC [) - if i + 1 < length and data[i + 1] == 91: # '[' + if i + 1 < length and data[i + 1] == 91: state = STATE_SEQUENCE i += 1 else: - # Record character (convert to UTF-8 based on encoding) if self.encoding == 'utf-8': - # For UTF-8, we need to decode multi-byte sequences char_bytes = bytearray([cursor]) - # Determine how many bytes this UTF-8 character needs if cursor < 0x80: - # Single byte (ASCII) pass elif cursor & 0xE0 == 0xC0: - # 2-byte sequence if i + 1 < length: i += 1 char_bytes.append(data[i]) elif cursor & 0xF0 == 0xE0: - # 3-byte sequence for _ in range(2): if i + 1 < length: i += 1 char_bytes.append(data[i]) elif cursor & 0xF8 == 0xF0: - # 4-byte sequence for _ in range(3): if i + 1 < length: i += 1 @@ -288,7 +336,6 @@ def parse(self, data: bytes) -> List[AnsiChar]: self._add_char(cp437_to_utf8(cursor)) elif state == STATE_SEQUENCE: - # Parse escape sequence seq_len = self._parse_sequence(data[i:]) i += seq_len state = STATE_TEXT @@ -302,13 +349,11 @@ def parse(self, data: bytes) -> List[AnsiChar]: def _add_char(self, char: str): """Add character to buffer with current attributes.""" - # Track max dimensions if self.column > self.column_max: self.column_max = self.column if self.row > self.row_max: self.row_max = self.row - # Apply invert/reverse if self.invert: bg = self.foreground % 8 fg = self.background + (self.foreground & 8) @@ -321,19 +366,10 @@ def _add_char(self, char: str): fg24 = self.foreground24 self.chars.append(AnsiChar( - column=self.column, - row=self.row, - background=bg, - foreground=fg, - background24=bg24, - foreground24=fg24, - character=char, - bold=self.bold, - italic=self.italic, - underline=self.underline, - blink=self.blink, - reverse=self.invert, - dim=self.dim + column=self.column, row=self.row, background=bg, foreground=fg, + background24=bg24, foreground24=fg24, character=char, + bold=self.bold, italic=self.italic, underline=self.underline, + blink=self.blink, reverse=self.invert, dim=self.dim )) self.column += 1 @@ -345,48 +381,33 @@ def _parse_sequence(self, data: bytes) -> int: for seq_len in range(max_len): seq_char = chr(data[seq_len]) if seq_len < len(data) else '' - # Cursor position (H or f) if seq_char in ('H', 'f'): self._handle_cursor_position(data[:seq_len]) return seq_len - - # Cursor up (A) if seq_char == 'A': n = self._parse_numeric(data[:seq_len], default=1) self.row = max(0, self.row - n) return seq_len - - # Cursor down (B) if seq_char == 'B': n = self._parse_numeric(data[:seq_len], default=1) self.row += n return seq_len - - # Cursor forward (C) if seq_char == 'C': n = self._parse_numeric(data[:seq_len], default=1) self.column = min(self.columns, self.column + n) return seq_len - - # Cursor backward (D) if seq_char == 'D': n = self._parse_numeric(data[:seq_len], default=1) self.column = max(0, self.column - n) return seq_len - - # Save cursor (s) if seq_char == 's': self.saved_row = self.row self.saved_column = self.column return seq_len - - # Restore cursor (u) if seq_char == 'u': self.row = self.saved_row self.column = self.saved_column return seq_len - - # Erase display (J) if seq_char == 'J': n = self._parse_numeric(data[:seq_len], default=0) if n == 2: @@ -396,18 +417,12 @@ def _parse_sequence(self, data: bytes) -> int: self.row_max = 0 self.chars.clear() return seq_len - - # Set graphics mode (m) if seq_char == 'm': self._handle_sgr(data[:seq_len]) return seq_len - - # PabloDraw 24-bit color (t) if seq_char == 't': self._handle_pablodraw_color(data[:seq_len]) return seq_len - - # Skip other sequences if 64 <= ord(seq_char) <= 126: return seq_len @@ -418,12 +433,10 @@ def _handle_cursor_position(self, seq: bytes): seq_str = seq.decode('ascii', errors='ignore') if seq_str.startswith(';'): - # ";column" format parts = seq_str[1:].split(';') row = 1 col = int(parts[0]) if parts and parts[0] else 1 else: - # "row;column" format parts = seq_str.split(';') row = int(parts[0]) if parts and parts[0] else 1 col = int(parts[1]) if len(parts) > 1 and parts[1] else 1 @@ -447,7 +460,6 @@ def _handle_sgr(self, seq: bytes): i += 1 continue - # Reset all attributes if val == 0: self.background = 0 self.background24 = 0 @@ -459,114 +471,78 @@ def _handle_sgr(self, seq: bytes): self.italic = False self.underline = False self.dim = False - - # Bold/bright elif val == 1: self.bold = True self.foreground = (self.foreground % 8) + 8 - self.foreground24 = 0 # Clear 24-bit when using bold - - # Dim + self.foreground24 = 0 elif val == 2: self.dim = True - - # Italic elif val == 3: self.italic = True - - # Underline elif val == 4: self.underline = True - - # Blink - elif val == 5: if self.icecolors: self.background = (self.background % 8) + 8 self.blink = False else: self.blink = True - - - # Invert/reverse elif val == 7: self.invert = True - - # Not bold elif val == 22: self.bold = False self.dim = False if self.foreground >= 8: self.foreground -= 8 - - # Not italic elif val == 23: self.italic = False - - # Not underlined elif val == 24: self.underline = False - - # Not blinking elif val == 25: self.blink = False if self.icecolors and self.background >= 8: self.background -= 8 - - # Not inverted elif val == 27: self.invert = False - - # Foreground color (30-37) elif 30 <= val <= 37: self.foreground = val - 30 self.foreground24 = 0 if self.bold: self.foreground += 8 - - # Extended foreground color (38) elif val == 38: if i + 2 < len(params): mode = int(params[i + 1]) - if mode == 5: # 256-color mode + if mode == 5: self.foreground = int(params[i + 2]) & 0xFF self.foreground24 = 0 i += 2 - elif mode == 2 and i + 4 < len(params): # 24-bit RGB mode + elif mode == 2 and i + 4 < len(params): r = int(params[i + 2]) & 0xFF g = int(params[i + 3]) & 0xFF b = int(params[i + 4]) & 0xFF self.foreground24 = (r << 16) | (g << 8) | b i += 4 - - # Background color (40-47) elif 40 <= val <= 47: self.background = val - 40 self.background24 = 0 if self.blink and self.icecolors: self.background += 8 - - # Extended background color (48) elif val == 48: if i + 2 < len(params): mode = int(params[i + 1]) - if mode == 5: # 256-color mode + if mode == 5: self.background = int(params[i + 2]) & 0xFF self.background24 = 0 i += 2 - elif mode == 2 and i + 4 < len(params): # 24-bit RGB mode + elif mode == 2 and i + 4 < len(params): r = int(params[i + 2]) & 0xFF g = int(params[i + 3]) & 0xFF b = int(params[i + 4]) & 0xFF self.background24 = (r << 16) | (g << 8) | b i += 4 - - # Bright foreground colors (90-97) elif 90 <= val <= 97: self.foreground = val - 90 + 8 self.foreground24 = 0 - - # Bright background colors (100-107) elif 100 <= val <= 107: self.background = val - 100 + 8 self.background24 = 0 @@ -582,15 +558,10 @@ def _handle_pablodraw_color(self, seq: bytes): return try: - # First param: 0=background, 1=foreground color_type = int(params[0]) - - # Next 3 params: R, G, B values (0-255) r = int(params[1]) & 0xFF if len(params) > 1 else 0 g = int(params[2]) & 0xFF if len(params) > 2 else 0 b = int(params[3]) & 0xFF if len(params) > 3 else 0 - - # Combine into 24-bit RGB value rgb = (r << 16) | (g << 8) | b if color_type == 0: @@ -610,38 +581,92 @@ def _parse_numeric(self, seq: bytes, default: int = 0) -> int: except ValueError: return default +def parse_sauce(data: bytes) -> Tuple[bytes, Optional[SauceRecord]]: + """Parse SAUCE record from file data.""" + if len(data) < 128: + return data, None + + sauce_offset = len(data) - 128 + sauce_block = data[sauce_offset:] + + if sauce_block[:5] != b'SAUCE': + return data, None + + sauce = SauceRecord() + sauce.title = sauce_block[7:42].rstrip(b'\x00').decode('cp437', errors='ignore') + sauce.author = sauce_block[42:62].rstrip(b'\x00').decode('cp437', errors='ignore') + sauce.group = sauce_block[62:82].rstrip(b'\x00').decode('cp437', errors='ignore') + sauce.date = sauce_block[82:90].decode('cp437', errors='ignore') + sauce.file_size = int.from_bytes(sauce_block[90:94], 'little') + sauce.data_type = sauce_block[94] + sauce.file_type = sauce_block[95] + sauce.t_info1 = int.from_bytes(sauce_block[96:98], 'little') + sauce.t_info2 = int.from_bytes(sauce_block[98:100], 'little') + sauce.t_info3 = int.from_bytes(sauce_block[100:102], 'little') + sauce.t_info4 = int.from_bytes(sauce_block[102:104], 'little') + num_comments = sauce_block[104] + sauce.t_flags = sauce_block[105] + sauce.t_info_s = sauce_block[106:128].rstrip(b'\x00').decode('cp437', errors='ignore') + + file_data = data[:sauce_offset] + + if num_comments > 0: + comment_size = num_comments * 64 + 5 + comment_offset = sauce_offset - comment_size + + if comment_offset >= 0: + comment_block = data[comment_offset:sauce_offset] + + if comment_block[:5] == b'COMNT': + for i in range(num_comments): + start = 5 + i * 64 + end = start + 64 + comment_line = comment_block[start:end].rstrip(b'\x00').decode('cp437', errors='ignore') + sauce.comments.append(comment_line) + + file_data = data[:comment_offset] + + if file_data and file_data[-1] == SUB: + file_data = file_data[:-1] + + return file_data, sauce def ans_to_ddw(input_path: str, output_path: str, columns: int = 80, icecolors: bool = False, encoding: str = 'cp437'): - """Convert ANSI file to DarkDraw format. - - Args: - input_path: Path to input .ans file - output_path: Path to output .ddw file - columns: Width in characters (default: 80) - icecolors: Enable iCE colors (blinking -> bright backgrounds) - encoding: Character encoding - 'cp437' (PC/DOS), 'iso8859-1' (Amiga), or 'utf-8' (default: 'cp437') - """ - # Read ANSI file + """Convert ANSI file to DarkDraw format.""" with open(input_path, 'rb') as f: data = f.read() - # Parse ANSI + file_data, sauce = parse_sauce(data) + + # Debug output + if sauce: + print(f"DEBUG: SAUCE record found") + print(f"DEBUG: Title: {sauce.title}") + print(f"DEBUG: Author: {sauce.author}") + print(f"DEBUG: to_ddw_rows returns {len(sauce.to_ddw_rows())} rows") + else: + print("DEBUG: No SAUCE record found") + parser = AnsiParser(columns=columns, icecolors=icecolors, encoding=encoding) - chars = parser.parse(data) + chars = parser.parse(file_data) - # Convert to DarkDraw rows - rows = [char.to_ddw_row() for char in chars] + rows = [] + + if sauce: + rows.extend(sauce.to_ddw_rows()) + + rows.extend([char.to_ddw_row(frame_id="ANSI art") for char in chars]) - # Write as JSONL with open(output_path, 'w', encoding='utf-8') as f: for row in rows: f.write(json.dumps(row) + '\n') - print(f"Converted {len(rows)} characters from {input_path} to {output_path}") + print(f"Converted {len(chars)} characters from {input_path} to {output_path}") print(f"Dimensions: {parser.column_max + 1} x {parser.row_max + 1}") print(f"Encoding: {encoding}") - + if sauce: + print(f"SAUCE: {sauce.title or '(no title)'} by {sauce.author or '(no author)'}") def main(): if len(sys.argv) < 3: @@ -679,6 +704,5 @@ def main(): ans_to_ddw(input_path, output_path, columns=columns, icecolors=icecolors, encoding=encoding) - if __name__ == '__main__': main() diff --git a/darkdraw/load_ans.py b/darkdraw/load_ans.py index b8163ce..f9c3747 100644 --- a/darkdraw/load_ans.py +++ b/darkdraw/load_ans.py @@ -12,7 +12,12 @@ @VisiData.api def open_ans(vd, p): # 1. Read raw bytes from source + from .ans2ddw import parse_sauce + data = p.read_bytes() + + # 1a. Extract SAUCE record if present + file_data, sauce = parse_sauce(data) # 2. Pull current global options enc_input = vd.options.ans_encoding.lower() @@ -36,10 +41,13 @@ def open_ans(vd, p): ) # 5. Parse the data into AnsiChar objects - chars = parser.parse(data) + chars = parser.parse(file_data) - # 6. Convert to rows using the AnsiChar transformation logic - rows = [char.to_ddw_row() for char in chars] + # 6. Convert to rows, including SAUCE if present + rows = [] + if sauce: + rows.extend(sauce.to_ddw_rows()) + rows.extend([char.to_ddw_row() for char in chars]) # 7. Generate JSONL output for DrawingSheet ddwoutput = '\n'.join(json.dumps(r) for r in rows) + '\n' From 450945c8c139d8dbd9cb2dad05242e31ee553b24 Mon Sep 17 00:00:00 2001 From: AdrianGroty <40133867+AdrianGroty@users.noreply.github.com> Date: Thu, 12 Feb 2026 16:41:13 -0600 Subject: [PATCH 06/11] general refactor cache rgb conversions refactor `to_ddw_row()` remove debug prints refactor sgr handling --- darkdraw/ans2ddw.py | 348 +++++++++++++++++--------------------------- 1 file changed, 133 insertions(+), 215 deletions(-) diff --git a/darkdraw/ans2ddw.py b/darkdraw/ans2ddw.py index 70f21a1..b2cd49b 100644 --- a/darkdraw/ans2ddw.py +++ b/darkdraw/ans2ddw.py @@ -5,6 +5,7 @@ import json from dataclasses import dataclass, field from typing import List, Optional, Tuple +from functools import lru_cache # Control characters LF = 10 @@ -84,6 +85,7 @@ def iso8859_1_to_utf8(byte_val: int) -> str: """Convert ISO-8859-1 byte value to UTF-8 character.""" return chr(byte_val) +@lru_cache(maxsize=4096) def rgb_to_256color(rgb: int) -> int: """Convert 24-bit RGB to nearest xterm 256 color code.""" r = (rgb >> 16) & 0xFF @@ -119,85 +121,12 @@ class SauceRecord: t_flags: int = 0 t_info_s: str = "" - def to_ddw_rows(self) -> List[dict]: - """Convert SAUCE record to DarkDraw text rows.""" - rows = [] - y = 0 - - if self.title: - rows.append({ - "type": "", "x": 0, "y": y, "text": self.title, - "color": "", "tags": [], "group": "", - "frame": "SAUCE record", "id": "Title", "rows": [] - }) - y += 1 - - if self.author: - rows.append({ - "type": "", "x": 0, "y": y, "text": self.author, - "color": "", "tags": [], "group": "", - "frame": "SAUCE record", "id": "Author", "rows": [] - }) - y += 1 - - if self.group: - rows.append({ - "type": "", "x": 0, "y": y, "text": self.group, - "color": "", "tags": [], "group": "", - "frame": "SAUCE record", "id": "Group", "rows": [] - }) - y += 1 - - if self.date: - rows.append({ - "type": "", "x": 0, "y": y, "text": self.date, - "color": "", "tags": [], "group": "", - "frame": "SAUCE record", "id": "Date", "rows": [] - }) - y += 1 - - if self.t_info1 or self.t_info2: - rows.append({ - "type": "", "x": 0, "y": y, "text": f"{self.t_info1}x{self.t_info2}", - "color": "", "tags": [], "group": "", - "frame": "SAUCE record", "id": "Dimensions", "rows": [] - }) - y += 1 - - if self.t_flags: - flags = [] - if self.t_flags & 0x01: - flags.append("non-blink") - if self.t_flags & 0x02: - flags.append("letter-spacing") - if self.t_flags & 0x04: - flags.append("aspect-ratio") - - rows.append({ - "type": "", "x": 0, "y": y, - "text": ", ".join(flags) if flags else str(self.t_flags), - "color": "", "tags": [], "group": "", - "frame": "SAUCE record", "id": "Flags", "rows": [] - }) - y += 1 - - if self.t_info_s: - rows.append({ - "type": "", "x": 0, "y": y, "text": self.t_info_s, - "color": "", "tags": [], "group": "", - "frame": "SAUCE record", "id": "Font", "rows": [] - }) - y += 1 - - for i, comment in enumerate(self.comments): - rows.append({ - "type": "", "x": 0, "y": y, "text": comment, - "color": "", "tags": [], "group": "", - "frame": "SAUCE record", "id": f"Comment {i+1}", "rows": [] - }) - y += 1 - - return rows +def sauce_to_row(self, y: int, text: str, label: str) -> dict: + return { + "type": "", "x": 0, "y": y, "text": text, + "color": "", "tags": [], "group": "", + "frame": "SAUCE record", "id": label, "rows": [] + } @dataclass class AnsiChar: @@ -217,37 +146,20 @@ class AnsiChar: dim: bool = False def to_ddw_row(self, frame_id: Optional[str] = None) -> dict: - """Convert to DarkDraw row format.""" - color_parts = [] - - if self.foreground24: - fg_256 = rgb_to_256color(self.foreground24) - color_parts.append(str(fg_256)) - else: - color_parts.append(str(self.foreground)) - - if self.background24: - bg_256 = rgb_to_256color(self.background24) - color_parts.append(f"on {bg_256}") - else: - color_parts.append(f"on {self.background}") + fg = str(rgb_to_256color(self.foreground24)) if self.foreground24 else str(self.foreground) + bg = f"on {rgb_to_256color(self.background24)}" if self.background24 else f"on {self.background}" - if self.bold: - color_parts.append("bold") - if self.italic: - color_parts.append("italic") - if self.underline: - color_parts.append("underline") - if self.blink: - color_parts.append("blink") - if self.reverse: - color_parts.append("reverse") - if self.dim: - color_parts.append("dim") + attrs = [fg, bg] + if self.bold: attrs.append("bold") + if self.italic: attrs.append("italic") + if self.underline: attrs.append("underline") + if self.blink: attrs.append("blink") + if self.reverse: attrs.append("reverse") + if self.dim: attrs.append("dim") return { "type": "", "x": self.column, "y": self.row, - "text": self.character, "color": " ".join(color_parts) if color_parts else "", + "text": self.character, "color": " ".join(attrs), "tags": [], "group": "", "frame": frame_id or "", "id": "", "rows": [] } @@ -286,6 +198,15 @@ def parse(self, data: bytes) -> List[AnsiChar]: i = 0 length = len(data) + # For UTF-8, decode once upfront + if self.encoding == 'utf-8': + try: + text = data.decode('utf-8', errors='replace') + return self._parse_unicode(text) + except Exception: + # Fallback if decode fails entirely + pass + while i < length: cursor = data[i] @@ -452,6 +373,24 @@ def _handle_sgr(self, seq: bytes): if not params: params = ['0'] + # Lookup tables for simple attribute toggles + ATTR_ON = { + 1: ('bold', True), + 2: ('dim', True), + 3: ('italic', True), + 4: ('underline', True), + 5: ('blink', True), + 7: ('invert', True), + } + + ATTR_OFF = { + 22: ['bold', 'dim'], + 23: ['italic'], + 24: ['underline'], + 25: ['blink'], + 27: ['invert'], + } + i = 0 while i < len(params): try: @@ -460,95 +399,106 @@ def _handle_sgr(self, seq: bytes): i += 1 continue + # Reset all if val == 0: self.background = 0 self.background24 = 0 self.foreground = 7 self.foreground24 = 0 - self.bold = False - self.blink = False - self.invert = False - self.italic = False - self.underline = False - self.dim = False - elif val == 1: - self.bold = True - self.foreground = (self.foreground % 8) + 8 - self.foreground24 = 0 - elif val == 2: - self.dim = True - elif val == 3: - self.italic = True - elif val == 4: - self.underline = True - elif val == 5: - if self.icecolors: + self.bold = self.blink = self.invert = False + self.italic = self.underline = self.dim = False + + # Simple attribute toggles + elif val in ATTR_ON: + attr, value = ATTR_ON[val] + setattr(self, attr, value) + if val == 1: # bold also brightens foreground + self.foreground = (self.foreground % 8) + 8 + self.foreground24 = 0 + elif val == 5 and self.icecolors: # blink in ice mode self.background = (self.background % 8) + 8 self.blink = False - else: - self.blink = True - elif val == 7: - self.invert = True - elif val == 22: - self.bold = False - self.dim = False - if self.foreground >= 8: + + # Attribute off + elif val in ATTR_OFF: + for attr in ATTR_OFF[val]: + setattr(self, attr, False) + if val == 22 and self.foreground >= 8: self.foreground -= 8 - elif val == 23: - self.italic = False - elif val == 24: - self.underline = False - elif val == 25: - self.blink = False - if self.icecolors and self.background >= 8: + elif val == 25 and self.icecolors and self.background >= 8: self.background -= 8 - elif val == 27: - self.invert = False + + # Foreground colors (30-37, 90-97) elif 30 <= val <= 37: - self.foreground = val - 30 + self.foreground = val - 30 + (8 if self.bold else 0) self.foreground24 = 0 - if self.bold: - self.foreground += 8 - elif val == 38: - if i + 2 < len(params): - mode = int(params[i + 1]) - if mode == 5: - self.foreground = int(params[i + 2]) & 0xFF - self.foreground24 = 0 - i += 2 - elif mode == 2 and i + 4 < len(params): - r = int(params[i + 2]) & 0xFF - g = int(params[i + 3]) & 0xFF - b = int(params[i + 4]) & 0xFF - self.foreground24 = (r << 16) | (g << 8) | b - i += 4 - elif 40 <= val <= 47: - self.background = val - 40 - self.background24 = 0 - if self.blink and self.icecolors: - self.background += 8 - elif val == 48: - if i + 2 < len(params): - mode = int(params[i + 1]) - if mode == 5: - self.background = int(params[i + 2]) & 0xFF - self.background24 = 0 - i += 2 - elif mode == 2 and i + 4 < len(params): - r = int(params[i + 2]) & 0xFF - g = int(params[i + 3]) & 0xFF - b = int(params[i + 4]) & 0xFF - self.background24 = (r << 16) | (g << 8) | b - i += 4 elif 90 <= val <= 97: - self.foreground = val - 90 + 8 + self.foreground = val - 82 # 90 - 8 = 82 self.foreground24 = 0 + + # Background colors (40-47, 100-107) + elif 40 <= val <= 47: + self.background = val - 40 + (8 if self.blink and self.icecolors else 0) + self.background24 = 0 elif 100 <= val <= 107: - self.background = val - 100 + 8 + self.background = val - 92 # 100 - 8 = 92 self.background24 = 0 + # 256-color / 24-bit color + elif val == 38: # foreground + consumed = self._handle_extended_color(params[i:], is_foreground=True) + i += consumed + elif val == 48: # background + consumed = self._handle_extended_color(params[i:], is_foreground=False) + i += consumed + i += 1 + def _handle_extended_color(self, params: list, is_foreground: bool) -> int: + """Handle 38/48 extended color sequences. Returns number of params consumed.""" + if len(params) < 3: + return 0 + + try: + mode = int(params[1]) + + # 256-color mode + if mode == 5: + color = int(params[2]) & 0xFF + if is_foreground: + self.foreground = color + self.foreground24 = 0 + else: + self.background = color + self.background24 = 0 + return 2 + + # 24-bit RGB mode + elif mode == 2 and len(params) >= 5: + r = int(params[2]) & 0xFF + g = int(params[3]) & 0xFF + b = int(params[4]) & 0xFF + rgb = (r << 16) | (g << 8) | b + if is_foreground: + self.foreground24 = rgb + else: + self.background24 = rgb + return 4 + except (ValueError, IndexError): + pass + + return 0 + + def _parse_rgb_params(self, params: list, start_idx: int) -> Optional[int]: + """Extract RGB value from parameter list. Returns RGB int or None.""" + try: + r = int(params[start_idx]) & 0xFF + g = int(params[start_idx + 1]) & 0xFF + b = int(params[start_idx + 2]) & 0xFF + return (r << 16) | (g << 8) | b + except (ValueError, IndexError): + return None + def _handle_pablodraw_color(self, seq: bytes): """Handle PabloDraw 24-bit ANSI color sequences (CSI...t).""" seq_str = seq.decode('ascii', errors='ignore') @@ -559,15 +509,12 @@ def _handle_pablodraw_color(self, seq: bytes): try: color_type = int(params[0]) - r = int(params[1]) & 0xFF if len(params) > 1 else 0 - g = int(params[2]) & 0xFF if len(params) > 2 else 0 - b = int(params[3]) & 0xFF if len(params) > 3 else 0 - rgb = (r << 16) | (g << 8) | b - - if color_type == 0: - self.background24 = rgb - elif color_type == 1: - self.foreground24 = rgb + rgb = self._parse_rgb_params(params, 1) + if rgb is not None: + if color_type == 0: + self.background24 = rgb + elif color_type == 1: + self.foreground24 = rgb except (ValueError, IndexError): pass @@ -639,35 +586,6 @@ def ans_to_ddw(input_path: str, output_path: str, columns: int = 80, file_data, sauce = parse_sauce(data) - # Debug output - if sauce: - print(f"DEBUG: SAUCE record found") - print(f"DEBUG: Title: {sauce.title}") - print(f"DEBUG: Author: {sauce.author}") - print(f"DEBUG: to_ddw_rows returns {len(sauce.to_ddw_rows())} rows") - else: - print("DEBUG: No SAUCE record found") - - parser = AnsiParser(columns=columns, icecolors=icecolors, encoding=encoding) - chars = parser.parse(file_data) - - rows = [] - - if sauce: - rows.extend(sauce.to_ddw_rows()) - - rows.extend([char.to_ddw_row(frame_id="ANSI art") for char in chars]) - - with open(output_path, 'w', encoding='utf-8') as f: - for row in rows: - f.write(json.dumps(row) + '\n') - - print(f"Converted {len(chars)} characters from {input_path} to {output_path}") - print(f"Dimensions: {parser.column_max + 1} x {parser.row_max + 1}") - print(f"Encoding: {encoding}") - if sauce: - print(f"SAUCE: {sauce.title or '(no title)'} by {sauce.author or '(no author)'}") - def main(): if len(sys.argv) < 3: print("Usage: ans2ddw.py [columns] [options]") From 0c4c81b28ce6bc1042ea7e0c0f7771166162cfd2 Mon Sep 17 00:00:00 2001 From: AdrianGroty <40133867+AdrianGroty@users.noreply.github.com> Date: Thu, 12 Feb 2026 16:45:19 -0600 Subject: [PATCH 07/11] Upload correct file oops --- darkdraw/ans2ddw.py | 40 ++++++++++++++++++++++++++++++++++------ 1 file changed, 34 insertions(+), 6 deletions(-) diff --git a/darkdraw/ans2ddw.py b/darkdraw/ans2ddw.py index b2cd49b..2bc3fcd 100644 --- a/darkdraw/ans2ddw.py +++ b/darkdraw/ans2ddw.py @@ -121,12 +121,40 @@ class SauceRecord: t_flags: int = 0 t_info_s: str = "" -def sauce_to_row(self, y: int, text: str, label: str) -> dict: - return { - "type": "", "x": 0, "y": y, "text": text, - "color": "", "tags": [], "group": "", - "frame": "SAUCE record", "id": label, "rows": [] - } + def sauce_to_rows(self) -> List[dict]: + """Convert SAUCE record to DarkDraw text rows.""" + fields = [ + (self.title, "Title"), + (self.author, "Author"), + (self.group, "Group"), + (self.date, "Date"), + ] + + if self.t_info1 or self.t_info2: + fields.append((f"{self.t_info1}x{self.t_info2}", "Dimensions")) + + if self.t_flags: + flags = [ + ("non-blink", 0x01), + ("letter-spacing", 0x02), + ("aspect-ratio", 0x04) + ] + flag_text = ", ".join(f for f, bit in flags if self.t_flags & bit) or str(self.t_flags) + fields.append((flag_text, "Flags")) + + if self.t_info_s: + fields.append((self.t_info_s, "Font")) + + fields.extend((comment, f"Comment {i}") for i, comment in enumerate(self.comments, 1)) + + return [ + { + "type": "", "x": 0, "y": y, "text": text, + "color": "", "tags": [], "group": "", + "frame": "SAUCE record", "id": label, "rows": [] + } + for y, (text, label) in enumerate((text, label) for text, label in fields if text) + ] @dataclass class AnsiChar: From c689c5efcb620d1d817a59055fce7d6e8545ce21 Mon Sep 17 00:00:00 2001 From: AdrianGroty <40133867+AdrianGroty@users.noreply.github.com> Date: Thu, 12 Feb 2026 16:50:24 -0600 Subject: [PATCH 08/11] nitpick SAUCE presentation move label from id column to type column --- darkdraw/ans2ddw.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/darkdraw/ans2ddw.py b/darkdraw/ans2ddw.py index 2bc3fcd..017a8b5 100644 --- a/darkdraw/ans2ddw.py +++ b/darkdraw/ans2ddw.py @@ -149,9 +149,9 @@ def sauce_to_rows(self) -> List[dict]: return [ { - "type": "", "x": 0, "y": y, "text": text, + "type": label, "x": 0, "y": y, "text": text, "color": "", "tags": [], "group": "", - "frame": "SAUCE record", "id": label, "rows": [] + "frame": "SAUCE record", "id": "", "rows": [] } for y, (text, label) in enumerate((text, label) for text, label in fields if text) ] From 28a5046de848a08c0599e156b7668241d1725030 Mon Sep 17 00:00:00 2001 From: AdrianGroty <40133867+AdrianGroty@users.noreply.github.com> Date: Fri, 13 Feb 2026 07:56:37 -0600 Subject: [PATCH 09/11] remove rgb>256 caching I have no idea what I'm doing. --- darkdraw/ans2ddw.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/darkdraw/ans2ddw.py b/darkdraw/ans2ddw.py index 017a8b5..d01f042 100644 --- a/darkdraw/ans2ddw.py +++ b/darkdraw/ans2ddw.py @@ -5,7 +5,6 @@ import json from dataclasses import dataclass, field from typing import List, Optional, Tuple -from functools import lru_cache # Control characters LF = 10 @@ -85,7 +84,6 @@ def iso8859_1_to_utf8(byte_val: int) -> str: """Convert ISO-8859-1 byte value to UTF-8 character.""" return chr(byte_val) -@lru_cache(maxsize=4096) def rgb_to_256color(rgb: int) -> int: """Convert 24-bit RGB to nearest xterm 256 color code.""" r = (rgb >> 16) & 0xFF From 5694ec053bb66ced43c3d3b6a1cbce98e0971a0f Mon Sep 17 00:00:00 2001 From: AdrianGroty <40133867+AdrianGroty@users.noreply.github.com> Date: Fri, 13 Feb 2026 08:46:38 -0600 Subject: [PATCH 10/11] add option for MS-DOS colors add visidata option to convert 16-color ANSI codes to VGA palette --- darkdraw/ans2ddw.py | 70 ++++++++++++++++++++++++++++++++++++++++---- darkdraw/load_ans.py | 9 ++++-- 2 files changed, 70 insertions(+), 9 deletions(-) diff --git a/darkdraw/ans2ddw.py b/darkdraw/ans2ddw.py index d01f042..98bc0c0 100644 --- a/darkdraw/ans2ddw.py +++ b/darkdraw/ans2ddw.py @@ -53,6 +53,26 @@ def _build_256_color_palette(): COLOR_256_PALETTE = _build_256_color_palette() +# VGA palette (MS-DOS text mode colors 0-15) +VGA_PALETTE = [ + (0, 0, 0), # 0: Black + (170, 0, 0), # 1: Red + (0, 170, 0), # 2: Green + (170, 85, 0), # 3: Yellow/Brown + (0, 0, 170), # 4: Blue + (170, 0, 170), # 5: Magenta + (0, 170, 170), # 6: Cyan + (170, 170, 170), # 7: Light Gray + (85, 85, 85), # 8: Dark Gray + (255, 85, 85), # 9: Light Red + (85, 255, 85), # 10: Light Green + (255, 255, 85), # 11: Yellow + (85, 85, 255), # 12: Light Blue + (255, 85, 255), # 13: Light Magenta + (85, 255, 255), # 14: Light Cyan + (255, 255, 255), # 15: White +] + # CP437 (DOS) to Unicode mapping for characters 128-255 CP437_TO_UNICODE = [ 0x00C7, 0x00FC, 0x00E9, 0x00E2, 0x00E4, 0x00E0, 0x00E5, 0x00E7, @@ -101,6 +121,15 @@ def rgb_to_256color(rgb: int) -> int: return best_match +def vga_to_256color(ansi_color: int) -> int: + """Convert ANSI color code (0-15) to nearest xterm 256 color using VGA palette.""" + if ansi_color < 0 or ansi_color >= len(VGA_PALETTE): + return ansi_color + + r, g, b = VGA_PALETTE[ansi_color] + rgb = (r << 16) | (g << 8) | b + return rgb_to_256color(rgb) + @dataclass class SauceRecord: """SAUCE metadata record.""" @@ -171,9 +200,20 @@ class AnsiChar: reverse: bool = False dim: bool = False - def to_ddw_row(self, frame_id: Optional[str] = None) -> dict: - fg = str(rgb_to_256color(self.foreground24)) if self.foreground24 else str(self.foreground) - bg = f"on {rgb_to_256color(self.background24)}" if self.background24 else f"on {self.background}" + def to_ddw_row(self, frame_id: Optional[str] = None, vga_colors: bool = False) -> dict: + if self.foreground24: + fg = str(rgb_to_256color(self.foreground24)) + elif vga_colors: + fg = str(vga_to_256color(self.foreground)) + else: + fg = str(self.foreground) + + if self.background24: + bg = f"on {rgb_to_256color(self.background24)}" + elif vga_colors: + bg = f"on {vga_to_256color(self.background)}" + else: + bg = f"on {self.background}" attrs = [fg, bg] if self.bold: attrs.append("bold") @@ -192,10 +232,11 @@ def to_ddw_row(self, frame_id: Optional[str] = None) -> dict: class AnsiParser: """Parse ANSI escape sequences and build character buffer.""" - def __init__(self, columns: int = 80, icecolors: bool = False, encoding: str = 'cp437'): + def __init__(self, columns: int = 80, icecolors: bool = False, encoding: str = 'cp437', vga_colors: bool = False): self.columns = columns self.icecolors = icecolors self.encoding = encoding + self.vga_colors = vga_colors self.background = 0 self.foreground = 7 @@ -605,12 +646,24 @@ def parse_sauce(data: bytes) -> Tuple[bytes, Optional[SauceRecord]]: return file_data, sauce def ans_to_ddw(input_path: str, output_path: str, columns: int = 80, - icecolors: bool = False, encoding: str = 'cp437'): + icecolors: bool = False, encoding: str = 'cp437', vga_colors: bool = False): """Convert ANSI file to DarkDraw format.""" with open(input_path, 'rb') as f: data = f.read() file_data, sauce = parse_sauce(data) + + parser = AnsiParser(columns=columns, icecolors=icecolors, encoding=encoding, vga_colors=vga_colors) + chars = parser.parse(file_data) + + rows = [] + if sauce: + rows.extend(sauce.sauce_to_rows()) + rows.extend([char.to_ddw_row(vga_colors=vga_colors) for char in chars]) + + with open(output_path, 'w', encoding='utf-8') as f: + for row in rows: + f.write(json.dumps(row) + '\n') def main(): if len(sys.argv) < 3: @@ -619,6 +672,7 @@ def main(): print() print("Options:") print(" --icecolors: enable iCE colors (blinking -> bright backgrounds)") + print(" --vga-colors: map ANSI colors to VGA palette before xterm-256 conversion") print(" --amiga: use ISO-8859-1 encoding (Amiga ANSI)") print(" --pc: use CP437 encoding (PC/DOS ANSI, default)") print(" --utf8: use UTF-8 encoding (modern ANSI)") @@ -629,6 +683,7 @@ def main(): columns = 80 icecolors = False encoding = 'cp437' + vga_colors = False if len(sys.argv) > 3: try: @@ -639,6 +694,9 @@ def main(): if '--icecolors' in sys.argv: icecolors = True + if '--vga-colors' in sys.argv: + vga_colors = True + if '--amiga' in sys.argv: encoding = 'iso8859-1' elif '--utf8' in sys.argv: @@ -646,7 +704,7 @@ def main(): elif '--pc' in sys.argv: encoding = 'cp437' - ans_to_ddw(input_path, output_path, columns=columns, icecolors=icecolors, encoding=encoding) + ans_to_ddw(input_path, output_path, columns=columns, icecolors=icecolors, encoding=encoding, vga_colors=vga_colors) if __name__ == '__main__': main() diff --git a/darkdraw/load_ans.py b/darkdraw/load_ans.py index f9c3747..0deeebb 100644 --- a/darkdraw/load_ans.py +++ b/darkdraw/load_ans.py @@ -8,6 +8,7 @@ vd.option('ans_columns', 80, 'width in characters for ANSI files') vd.option('ans_icecolors', False, 'enable iCE colors (blinking -> bright backgrounds)') vd.option('ans_encoding', 'cp437', 'character encoding: cp437/dos, iso8859-1/amiga, or utf-8') +vd.option('ans_vga_colors', False, 'convert SGR color codes to VGA palette when importing .ans files') @VisiData.api def open_ans(vd, p): @@ -23,6 +24,7 @@ def open_ans(vd, p): enc_input = vd.options.ans_encoding.lower() cols = vd.options.ans_columns ice = vd.options.ans_icecolors + vga = vd.options.ans_vga_colors # 3. Handle aliases for encoding # Map 'dos' -> 'cp437' and 'amiga' -> 'iso8859-1' to match AnsiParser logic @@ -37,7 +39,8 @@ def open_ans(vd, p): parser = AnsiParser( columns=cols, icecolors=ice, - encoding=enc + encoding=enc, + vga_colors=vga ) # 5. Parse the data into AnsiChar objects @@ -46,8 +49,8 @@ def open_ans(vd, p): # 6. Convert to rows, including SAUCE if present rows = [] if sauce: - rows.extend(sauce.to_ddw_rows()) - rows.extend([char.to_ddw_row() for char in chars]) + rows.extend(sauce.sauce_to_rows()) + rows.extend([char.to_ddw_row(vga_colors=vga) for char in chars]) # 7. Generate JSONL output for DrawingSheet ddwoutput = '\n'.join(json.dumps(r) for r in rows) + '\n' From 2ea1b88febd9f76bbdc382703d013d6a0a7b3ea6 Mon Sep 17 00:00:00 2001 From: AdrianGroty <40133867+AdrianGroty@users.noreply.github.com> Date: Fri, 13 Feb 2026 09:02:25 -0600 Subject: [PATCH 11/11] nitpick RGB>256 conversion use `16` instead of `0` when converting RGB (0, 0, 0) --- darkdraw/ans2ddw.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/darkdraw/ans2ddw.py b/darkdraw/ans2ddw.py index 98bc0c0..90b8c3b 100644 --- a/darkdraw/ans2ddw.py +++ b/darkdraw/ans2ddw.py @@ -110,6 +110,10 @@ def rgb_to_256color(rgb: int) -> int: g = (rgb >> 8) & 0xFF b = rgb & 0xFF + # Special case: map black (0,0,0) to index 16 instead of 0 + if r == 0 and g == 0 and b == 0: + return 16 + best_match = 0 best_distance = float('inf')