import { Resvg } from "@resvg/resvg-js"; import { promises } from "fs"; import { JSDOM } from "jsdom"; import * as d3 from "d3"; import yargs from "yargs/yargs"; import { hideBin } from "yargs/helpers"; import spawn from "await-spawn"; import random from "random"; const xmlns = "http://www.w3.org/2000/xmlns/"; const xlinkns = "http://www.w3.org/1999/xlink"; const svgns = "http://www.w3.org/2000/svg"; import {RADIUS_OPTS, RADIUS_DESC, DotMaker} from './src/components/dots.js'; import {PALETTES, ColourNamer} from './src/components/palettes.js'; const CELL = 10; const MAG = 2; const WIDTH = 20; const HEIGHT = WIDTH; const VISIBLE_DOG = 1000; function randomise_params() { const palette_name = random.choice(Array.from(PALETTES.keys())); const palette_fn = PALETTES.get(palette_name); const palette = palette_fn(); const patterns = [1,2].map((n) => { return { i: n, colour: palette[n], m: random.choice([1, 2, 3, 4, 5]), n: random.choice([1, 2, 3, 4, 5]), f: random.choice(RADIUS_OPTS), r: random.float(0, 0.4), }}); return { background: palette[0], palette: palette_name, patterns: patterns } } // lol the best way I found to to this was imagemagick! async function get_histogram(imgfile) { try { const bl = await spawn('convert', [ imgfile, '-format', '%c', 'histogram:info:' ]); return parse_histogram(bl.toString()); } catch (e) { console.log(e); } } function parse_histogram(convert_out) { const colour_re = /(\d+): \(\d+,\d+,\d+,\d+\) (#[A-F0-9]+) /; const colours = new Map(); convert_out.split("\n").forEach((l) => { const m = l.match(colour_re); if( m ) { const colour = m[2].substring(0, 7); colours.set(colour, Number(m[1])); } }); return new Map([...colours].sort((a, b) => b[1] - a[1])); } function colour_visible(hist, colour) { const hexcolour = colour.formatHex().toUpperCase(); return hist.get(hexcolour) > VISIBLE_DOG; } function image_description(namer, params, histogram) { const colours = [ params.background, params.patterns[0].colour, params.patterns[1].colour, ]; const i_vis = [0, 1, 2].filter((i) => colour_visible(histogram, colours[i])); const named_colours = i_vis.map((i) => namer.colour_to_text(colours[i])); const gradients = i_vis.map((i) => { if( i > 0 ) { return RADIUS_DESC[params.patterns[i - 1].f]; } else { return ''; } }); if( named_colours.length == 1 ) { return `A solid field of ${named_colours[0]}`; } if( named_colours.length > 1 ) { const bgc = named_colours[0]; const a = bgc.match(/^[aeiou]/) ? 'an' : 'a'; const dot_desc = named_colours.slice(1); const gs = gradients.slice(1); const pattern_desc = dot_desc.map((d, i) => `${d} dots ${gs[i]}`); const patterns = pattern_desc.join(' and '); return `A pattern of ${patterns} on ${a} ${bgc} background`; } return ""; } function poptimal_svg(params) { const window = new JSDOM().window; const document = window.document; const container = d3.select(document.body).append("div"); const dm = new DotMaker(WIDTH); const svg = container.append("svg") .attr("width", WIDTH * CELL * MAG) .attr("height", HEIGHT * CELL * MAG) .attr("viewBox", [ 0, 0, WIDTH, HEIGHT ]); const background = svg.append("rect") .attr("x", 0) .attr("y", 0) .attr("width", WIDTH) .attr("height", WIDTH) .attr("fill", params.background); params.patterns.map((p) => { const dots = dm.dots(1 / p.m, p.n); const dots_g = svg.append("g") .attr("id", `dots${p.i}`); dots_g.selectAll("circle") .data(dots) .join("circle") .attr("r", (d) => dm.radius(d, p.f, p.r)) .attr("fill", p.colour) .attr("cx", (d) => d.x) .attr("cy", (d) => d.y); }); const node = svg.node(); node.setAttributeNS(xmlns, "xmlns", svgns); node.setAttributeNS(xmlns, "xmlns:xlink", xlinkns); const serializer = new window.XMLSerializer; return serializer.serializeToString(node); } async function post_image(image, alt_text, cf) { const status_url = `${cf.base_url}/api/v1/statuses`; const media_url = `${cf.base_url}/api/v1/media`; const headers = { 'Authorization': `Bearer ${cf.access_token}`, }; const rawData = await promises.readFile(image); const blob = new Blob([rawData]); const fd = new FormData(); fd.set('description', alt_text); fd.set('file', blob, image, 'text/png'); const resp = await fetch(media_url, { method: 'POST', headers: headers, body: fd }); const bodyjson = await resp.text(); const response = JSON.parse(bodyjson); const media_id = response["id"]; headers['Accept'] = 'application/json'; headers['Content-Type'] = 'application/json'; const resp2 = await fetch(status_url, { method: 'POST', headers: headers, body: JSON.stringify({ media_ids: [ media_id ] }) }); const bodyjson2 = await resp2.text(); } async function main() { const argv = yargs(hideBin(process.argv)) .usage("Usage: -s SIZE -o output.png -c config.json") .default('s', 1200) .default('c', 'config.json').argv; const cfjson = await promises.readFile(argv.c); const cf = JSON.parse(cfjson); const fn = argv.o || String(Date.now()) + '.png'; const imgfile = cf['working_dir'] + '/' + fn; const params = randomise_params(); const colourf = params.palette === 'grayscale' ? cf['grayscale'] : cf['colour']; const namer = new ColourNamer(); await namer.load_colours(colourf); const svg = poptimal_svg(params); const opts = { background: 'rgba(255, 255, 255, 1.0)', fitTo: { mode: 'width', value: argv.s, }, }; const resvg = new Resvg(svg, opts); const pngData = resvg.render(); const pngBuffer = pngData.asPng(); await promises.writeFile(imgfile, pngBuffer); // generate the alt_text last to check the image file histogram // so we don't include obscured colours const hist = await get_histogram(imgfile); const alt_text = image_description(namer, params, hist); console.log(alt_text); console.log(imgfile); if( cf['base_url'] ) { await post_image(imgfile, alt_text, cf); } } main();