{"slug": "how-to-turn-text-into-colors-without-ai", "title": "How to turn text into colors (without AI)", "summary": "The article explains a method to generate a color palette from text without using AI, based on the HSL color model. It involves analyzing character frequency in the text, mapping each unique character to a specific hue on the color wheel, and using the character's frequency to determine saturation, with lightness set to a default 50%. The process includes cleaning and normalizing the text data, such as optionally excluding whitespace and making characters case-insensitive, before converting the frequency data into HSL values.", "body_md": "You definitely know that you can generate an image from text by simply feeding the prompt to Nano Banana or an equivalent. What if I tell you that you can generate a color palette out of your text, without any help from AI?\n\nMy idea is nothing complex. Just look at the HSL [color model](https://giggster.com/guide/basics/hue-saturation-lightness/).\n\nAs you can see, to represent color, we need three numbers - hue degrees, saturation percentages, and lightness percentages. All we have to do is get those numbers out of text.\n\nThere are a few ways to do so, but one of the most common, logical, and foolproof is to create an alphabet first and then analyze character frequency second. Let me show you.\n\nFor starters, we need a text. I took [Christmas](https://lingua.com/english/reading/christmas/) from Lingua.com. If we ran character frequency analysis (as is), we would end up with something like this:\n\n```\n[\n  {\"character\": \" \", \"count\": 307},\n  {\"character\": \"e\", \"count\": 195},\n  {\"character\": \"a\", \"count\": 137},\n  {\"character\": \"t\", \"count\": 134},\n  {\"character\": \"i\", \"count\": 120},\n  {\"character\": \"s\", \"count\": 115},\n  {\"character\": \"o\", \"count\": 97},\n  {\"character\": \"h\", \"count\": 94},\n  {\"character\": \"r\", \"count\": 92},\n  {\"character\": \"n\", \"count\": 91},\n  {\"character\": \"l\", \"count\": 86},\n  {\"character\": \"d\", \"count\": 79},\n  {\"character\": \"c\", \"count\": 49},\n  {\"character\": \"m\", \"count\": 43},\n  {\"character\": \"y\", \"count\": 39},\n  {\"character\": \"p\", \"count\": 35},\n  {\"character\": \",\", \"count\": 31},\n  {\"character\": \"g\", \"count\": 28},\n  {\"character\": \"f\", \"count\": 27},\n  {\"character\": \"u\", \"count\": 21},\n  {\"character\": \"b\", \"count\": 20},\n  {\"character\": \"w\", \"count\": 20},\n  {\"character\": \"v\", \"count\": 11},\n  {\"character\": \"C\", \"count\": 10},\n  {\"character\": \".\", \"count\": 9},\n  {\"character\": \"S\", \"count\": 7},\n  {\"character\": \"k\", \"count\": 7},\n  {\"character\": \"-\", \"count\": 6},\n  {\"character\": \"A\", \"count\": 5},\n  {\"character\": \"(\", \"count\": 5},\n  {\"character\": \")\", \"count\": 5},\n  {\"character\": \"'\", \"count\": 5},\n  {\"character\": \"\\n\", \"count\": 4},\n  {\"character\": \"j\", \"count\": 4},\n  {\"character\": \"J\", \"count\": 3},\n  {\"character\": \"T\", \"count\": 2},\n  {\"character\": \"N\", \"count\": 2},\n  {\"character\": \"x\", \"count\": 2},\n  {\"character\": \"G\", \"count\": 2},\n  {\"character\": \"L\", \"count\": 2},\n  {\"character\": \"\\\"\", \"count\": 2},\n  {\"character\": \"B\", \"count\": 2},\n  {\"character\": \"U\", \"count\": 1},\n  {\"character\": \"2\", \"count\": 1},\n  {\"character\": \"5\", \"count\": 1},\n  {\"character\": \"D\", \"count\": 1},\n  {\"character\": \"P\", \"count\": 1},\n  {\"character\": \"q\", \"count\": 1},\n  {\"character\": \"/\", \"count\": 1}\n]\n```\n\nUnsurprisingly, the whitespace is the most frequent symbol - it's a common thing for texts in the English language and other Western languages. If we were to process letters only (case-insensitive), the array would look like this:\n\n```\n[\n  { \"character\": \"e\", \"count\": 195 },\n  { \"character\": \"a\", \"count\": 142 },\n  { \"character\": \"t\", \"count\": 136 },\n  { \"character\": \"s\", \"count\": 122 },\n  { \"character\": \"i\", \"count\": 120 },\n  { \"character\": \"o\", \"count\": 97 },\n  { \"character\": \"h\", \"count\": 94 },\n  { \"character\": \"n\", \"count\": 93 },\n  { \"character\": \"r\", \"count\": 92 },\n  { \"character\": \"l\", \"count\": 88 },\n  { \"character\": \"d\", \"count\": 80 },\n  { \"character\": \"c\", \"count\": 59 },\n  { \"character\": \"m\", \"count\": 43 },\n  { \"character\": \"y\", \"count\": 39 },\n  { \"character\": \"p\", \"count\": 36 },\n  { \"character\": \"g\", \"count\": 30 },\n  { \"character\": \"f\", \"count\": 27 },\n  { \"character\": \"b\", \"count\": 22 },\n  { \"character\": \"u\", \"count\": 22 },\n  { \"character\": \"w\", \"count\": 20 },\n  { \"character\": \"v\", \"count\": 11 },\n  { \"character\": \"j\", \"count\": 7 },\n  { \"character\": \"k\", \"count\": 7 },\n  { \"character\": \"x\", \"count\": 2 },\n  { \"character\": \"q\", \"count\": 1 }\n]\n```\n\nOur next task is to take the [HSL color wheel](https://www.js-craft.io/blog/an-introduction-to-hsl-colors-in-css/)\n\nForm a \"string\" out of symbols and \"wrap\" it around the wheel\n\nBy dividing the wheel into equal parts according to our \"alphabet,\" we got the **hues**. The frequency of symbols got us **saturation**. And **luminosity** has a default value - 50% for *pure* colors. Of course, nothing stops you from making luminosity a variable.\n\nAll right, let's go to code:\n\nFirst step, cleaning up and normalising text data according to our preferences - include or not whitespaces, numbers, etc.\n\n``` js\n    let rawChars = text.split('');\n\n    const filteredChars = rawChars.filter(char => {\n\n        // any whitespace\n        const isSpace = /\\p{Separator}/u.test(char);\n\n        // punctuation in any script\n        const isPunct = /\\p{P}/u.test(char);\n\n        // any numeric digit in any language\n        const isDigit = /\\p{N}/u.test(char);\n\n        // any letter\n        const isLetter = /\\p{L}/u.test(char);\n\n        // any symbol\n        const isSymbol = /\\p{S}/u.test(char);\n\n        if (!settings.includeWhitespace && isSpace) return false;\n        if (!settings.includePunctuation && isPunct) return false;\n        if (!settings.includeDigits && isDigit) return false;\n        if (!settings.includeSymbols && isSymbol) return false;\n        return true;\n    });\n\n    const workingText = filteredChars.join('');\n```\n\nSecond is making a frequency table\n\n``` js\n  const charMap = {};\n  filteredChars.forEach(char => {\n      const key = settings.caseSensitive ? char : char.toLowerCase();\n      charMap[key] = (charMap[key] || 0) + 1;\n  });\n```\n\nNext is making auxiliary processing\n\n``` js\n  let minCodePoint = Infinity;\n  let maxCodePoint = -Infinity;\n  let maxFreq = 0;\n\n  const data = entries.map(([char, count]) => {\n    const preparedChar = settings.caseSensitive ? char : char.toLowerCase();\n    const codepoint = preparedChar.codePointAt(0);\n    if (codepoint < minCodePoint) minCodePoint = codepoint;\n    if (codepoint > maxCodePoint) maxCodePoint = codepoint;\n    if (count > maxFreq) maxFreq = count;\n    return { char, count, codepoint };\n  });\n\n  return { \n    data, \n    minCodePoint, \n    maxCodePoint, \n    maxFreq, \n    range: (maxCodePoint - minCodePoint) || 1 \n  };\n```\n\nFinally, HSL calculations\n\n``` js\nexport const getPalette = (context) => {\n  if (!context) return [];\n\n  return context.data.map(({ char, count, codepoint }) => {\n    const h = Math.round(((codepoint - context.minCodePoint) / context.range) * 360);\n    const s = Math.round((count / context.maxFreq) * 100);\n    const l = 50;\n\n    return {\n      char,\n      count,\n      h, s, l,\n      hsl: `hsl(${h}, ${s}%, ${l}%)`\n    };\n  });\n};\n```\n\nAnyway, let's see what the palette for our Christmas text will look like. I made two types of data visualization for that - Tile Chart and [Bar Chart](https://datavizcatalogue.com/methods/bar_chart.html).\n\nA lot of interesting stuff is going on here. We can go the full naive approach, collect all the highest-scoring colors, and call it a day.\n\nBut here is the problem - it's way too \"loud\", [too vivid and intense](https://coolors.co/visualizer/dd2222-8e7871-a68059-b49a4b-ffff00) to be a useful color palette.\n\nOne way to overcome it will be to use the [weighted clustering](https://en.wikipedia.org/wiki/Cluster-weighted_modeling) algorithm. Long story short:\n\n1) we split the 360° color wheel into 30° sectors (buckets), since the standard color wheel has 12 colors, and 360/12 is 30.\n\n2) we push each color into the corresponding bucket\n\n3) we calculate the weight of each bucket (high saturation is high weight)\n\n4) we calculate an average hue and average saturation for the sum of colors in each bucket\n\n5) ???\n\n6) PROFIT (there are no guarantees it will make a suitable palette, though, so proceed with caution)\n\nStraight to the code:\n\n```\nexport default function getWeightedClusters(palette: PaletteItem[]): ColorCluster[] {\n    const buckets: Bucket[] = Array.from({ length: 12 }, () => ({\n      totalWeight: 0,\n      sumX: 0,\n      sumY: 0,\n      count: 0,\n      originalColors: []\n    }));\n\n    palette.forEach(item => {\n      // 1. Parse HSL\n      const {h, s, l, char} = item;\n\n      // 2. Calculate Weight (Favor high saturation)\n      const weight = Math.pow(s, 2) + 1; // +1 to give very desaturated colors a tiny vote\n\n      // 3. Determine Bucket (0-11)\n      // We shift by 15 degrees so the primary colors are in the center of buckets\n      const bucketIndex = Math.floor(((h + 15) % 360) / 30);\n\n      // 4. Convert Hue to Vector (Radians)\n      const rad = (h * Math.PI) / 180;\n      const x = Math.cos(rad) * weight;\n      const y = Math.sin(rad) * weight;\n\n      // 5. Accumulate\n      const b = buckets[bucketIndex];\n      b.totalWeight += weight;\n      b.sumX += x;\n      b.sumY += y;\n      b.count++;\n      b.originalColors.push({ h, s, l, weight, char });\n    });\n\n    // 6. Finalize Clusters\n    return buckets\n      .map((b, index) => {\n        if (b.count === 0) return null;\n\n        // Convert vector average back to Hue\n        let avgHue = (Math.atan2(b.sumY, b.sumX) * 180) / Math.PI;\n        if (avgHue < 0) avgHue += 360;\n\n        // Weighted average Saturation\n        const avgSat = b.originalColors.reduce((acc, c) => acc + (c.s * c.weight), 0) / b.totalWeight;\n\n        const bucketChars = b.originalColors.map(item => {return {char: item.char, color: `hsl(${item.h}, ${item.s}%, 50%)`}});\n\n        return {\n          id: index,\n          representativeHue: Math.round(avgHue),\n          representativeSat: Math.round(avgSat),\n          strength: b.totalWeight,\n          density: b.count,\n          chars: bucketChars\n        } as ColorCluster;\n      })\n      .filter((c): c is ColorCluster => c !== null);\n  }\n```\n\nAnd visualization (I've made a color wheel and pack of cards):\n\nCool, huh? But it works not only with Latin alphabet-based languages, but (theoretically) supports everything.\n\nHere is text in Japanese I took from this site, [Reading Passage 3](https://japanesetest4you.com/japanese-language-proficiency-test-jlpt-n5-reading-exercise-2/)\n\nThat's actually only a small part of what could be done. We can calculate the average hue and saturation of the text and generate a palette from [just one color](https://mycolor.space/?hex=%23A79F32&sub=1), for example.\n\nYou can look at the code [here](https://github.com/al3xsus/text-to-palette)\n\nYou can look and play with the tool [here](https://al3xsus.github.io/text-to-palette/)\n\nThanks for coming to my TED talk. Feel free to reach me in comments or anywhere.\n\nP.S. The cover image was created using a color palette derived from this post. R is for recursion.", "url": "https://wpnews.pro/news/how-to-turn-text-into-colors-without-ai", "canonical_source": "https://dev.to/al3xsus/how-to-turn-text-into-colors-without-ai-224d", "published_at": "2026-05-22 12:15:52+00:00", "updated_at": "2026-05-22 12:37:56.759550+00:00", "lang": "en", "topics": ["data"], "entities": ["Nano Banana", "Lingua.com"], "alternates": {"html": "https://wpnews.pro/news/how-to-turn-text-into-colors-without-ai", "markdown": "https://wpnews.pro/news/how-to-turn-text-into-colors-without-ai.md", "text": "https://wpnews.pro/news/how-to-turn-text-into-colors-without-ai.txt", "jsonld": "https://wpnews.pro/news/how-to-turn-text-into-colors-without-ai.jsonld"}}