Skip to content

实现 图片转 ASCII 字符画 工具

分类:developer | 标签:ASCII、字符画、图片 将图片转换为 ASCII 字符画

2.1 功能说明

将图片转换为 ASCII 字符画

2.1.1 使用指南

功能说明

把本地图片转换成等宽字符组成的 ASCII 字符画。原理是先把图片缩放到指定的「字符列数」, 逐像素计算灰度亮度(luminance = 0.299R + 0.587G + 0.114B),再按亮度深浅映射到一个从深到浅排列的字符集, 亮度越低(越暗)用笔画越密的字符(如 @),亮度越高(越亮)用越稀疏的字符(如空格)。

参数说明

字符列数:结果每行的字符个数,数值越大细节越多、文本也越长。一般 80~120 适合正文粘贴,160 以上适合导出图片查看。

字符宽高比:等宽字体的字符通常「高大于宽」,直接按图片比例取行数会导致画面被拉长。默认 0.5 表示按半高采样,可根据实际显示字体微调。

字符集:内置经典 10 级、70 级精细、方块字符、二值等预设,也可以自定义。请务必按「从暗到亮」的顺序书写,第一个字符代表最暗的像素。

反色:适合深色背景上的浅色主体,或者你打算把字符画放在黑底上显示。

亮度 / 对比度:在灰度映射之前做一次调整,可以救回过曝或过暗的图片,让字符层次更分明。

导出说明

导出 TXT:保存为纯文本,粘贴到任何等宽字体环境(终端、代码块)都能正常显示,注意接收方必须使用等宽字体,否则会错位。

导出 PNG:把字符逐行绘制到 canvas 上再导出图片,可选深色底、浅色底或透明底。透明底导出的是深色文字,适合叠加在浅色页面上。

隐私说明

全部处理都在你的浏览器本地完成,图片不会上传到任何服务器,也不产生网络请求。

使用建议

轮廓清晰、主体与背景反差大的图片(人像剪影、logo、图标)效果最好;细节繁杂的风景照建议提高列数并适当增加对比度。

2.2 代码实现

2.2.1 组件结构

本工具是一个基于 Vue 3 <script setup> 语法的单文件组件(SFC),统一包裹在 ToolPageShell 组件内,由它提供页面标题、工具 ID、分类与「使用指南」插槽等通用外壳;核心业务逻辑(响应式状态、计算属性、事件处理函数)全部写在 <script setup> 中,输入/输出通过 el-inputel-button 等 Element Plus 组件与用户交互,所有数据均在浏览器本地处理,不会上传服务器。

2.2.2 核心逻辑概览

<script setup> 中定义的主要函数/方法:

  • pickFile()
  • onFileChange()
  • onDrop()
  • loadFile()
  • render()
  • scheduleRender()
  • resetParams()
  • copyText()
  • done()
  • baseName()
  • downloadBlob()
  • exportTxt()
  • exportPng()

2.2.3 关键实现代码

以下是该工具的完整 <script setup> 实现(核心代码)。模板(<template>)与样式(<style>)已省略。

vue
import { ref, computed, watch, onBeforeUnmount } from 'vue'
import ToolPageShell from '@/components/ToolPageShell.vue'

const presets = [
  { key: 'classic', label: '经典 10 级(@%#*+=-:. )', ramp: '@%#*+=-:. ' },
  { key: 'fine', label: '精细 70 级(层次最丰富)', ramp: '$@B%8&WM#*oahkbdpqwmZO0QLCJUYXzcvunxrjft/|()1{}[]?-_+~<>i!lI;:,"^`\'. ' },
  { key: 'simple', label: '简约 6 级(#@+-. )', ramp: '#@+-. ' },
  { key: 'block', label: '方块字符(实心到空心)', ramp: '\u2588\u2593\u2592\u2591 ' },
  { key: 'binary', label: '二值(01)', ramp: '01' },
  { key: 'dot', label: '点阵(密到疏)', ramp: '\u25CF\u25D5\u25D1\u25D4\u25CB ' },
  { key: 'custom', label: '自定义…', ramp: '' }
]

const fileInput = ref(null)
const dragOver = ref(false)
const error = ref('')
const loaded = ref(false)
const fileName = ref('')

const imgW = ref(0)
const imgH = ref(0)

const cols = ref(100)
const charAspect = ref(0.5)
const brightness = ref(0)
const contrast = ref(0)
const presetKey = ref('classic')
const customRamp = ref('@%#*+=-:. ')
const invert = ref(false)
const darkPreview = ref(true)
const previewFont = ref(6)
const pngFont = ref(12)
const pngBg = ref('dark')
const copied = ref(false)

const asciiText = ref('')
const outCols = ref(0)
const outRows = ref(0)

let imageEl = null
let objectUrl = ''

const activeRamp = computed(() => {
  if (presetKey.value === 'custom') {
    return customRamp.value.length > 0 ? customRamp.value : '@%#*+=-:. '
  }
  const found = presets.find((p) => p.key === presetKey.value)
  return found ? found.ramp : '@%#*+=-:. '
})

const charCount = computed(() => outCols.value * outRows.value)

function pickFile() {
  if (fileInput.value) fileInput.value.click()
}

function onFileChange(e) {
  const f = e.target.files && e.target.files[0]
  if (f) loadFile(f)
  e.target.value = ''
}

function onDrop(e) {
  dragOver.value = false
  const f = e.dataTransfer.files && e.dataTransfer.files[0]
  if (f) loadFile(f)
}

function loadFile(file) {
  error.value = ''
  copied.value = false
  if (!file.type || !file.type.startsWith('image/')) {
    error.value = '请选择图片文件(JPG / PNG / WEBP / GIF / BMP)'
    return
  }
  if (objectUrl) URL.revokeObjectURL(objectUrl)
  objectUrl = URL.createObjectURL(file)
  fileName.value = file.name

  const img = new Image()
  img.onload = () => {
    imageEl = img
    imgW.value = img.naturalWidth
    imgH.value = img.naturalHeight
    loaded.value = true
    render()
  }
  img.onerror = () => {
    error.value = '图片解码失败,请换一张图片再试'
    loaded.value = false
  }
  img.src = objectUrl
}

function render() {
  if (!imageEl) return
  const ramp = activeRamp.value
  const rampChars = Array.from(ramp)
  if (rampChars.length < 2) {
    error.value = '字符集至少需要 2 个字符'
    return
  }
  error.value = ''

  const w = Math.max(4, Math.min(240, Math.round(cols.value)))
  const ratio = imgH.value / imgW.value
  const h = Math.max(2, Math.round(w * ratio * charAspect.value))

  const canvas = document.createElement('canvas')
  canvas.width = w
  canvas.height = h
  const ctx = canvas.getContext('2d', { willReadFrequently: true })
  ctx.drawImage(imageEl, 0, 0, w, h)

  let data
  try {
    data = ctx.getImageData(0, 0, w, h).data
  } catch (err) {
    error.value = '无法读取像素数据:' + (err && err.message ? err.message : '未知错误')
    return
  }

  // 对比度系数(标准公式)
  const c = contrast.value
  const cf = (259 * (c + 255)) / (255 * (259 - c))
  const b = brightness.value * 2.55
  const maxIndex = rampChars.length - 1

  const lines = new Array(h)
  for (let y = 0; y < h; y++) {
    const row = new Array(w)
    for (let x = 0; x < w; x++) {
      const i = (y * w + x) * 4
      const alpha = data[i + 3] / 255
      // 透明像素按白色(最亮)处理,避免透明区域出现噪点
      let lum = 0.299 * data[i] + 0.587 * data[i + 1] + 0.114 * data[i + 2]
      lum = lum * alpha + 255 * (1 - alpha)
      lum = cf * (lum - 128) + 128 + b
      if (lum < 0) lum = 0
      if (lum > 255) lum = 255
      if (invert.value) lum = 255 - lum
      let idx = Math.round((lum / 255) * maxIndex)
      if (idx < 0) idx = 0
      if (idx > maxIndex) idx = maxIndex
      row[x] = rampChars[idx]
    }
    lines[y] = row.join('')
  }

  outCols.value = w
  outRows.value = h
  asciiText.value = lines.join('\n')
}

let timer = null
function scheduleRender() {
  if (timer) clearTimeout(timer)
  timer = setTimeout(() => {
    timer = null
    render()
  }, 80)
}

watch([cols, charAspect, brightness, contrast, presetKey, customRamp, invert], () => {
  if (loaded.value) scheduleRender()
})

function resetParams() {
  cols.value = 100
  charAspect.value = 0.5
  brightness.value = 0
  contrast.value = 0
  presetKey.value = 'classic'
  invert.value = false
}

function copyText() {
  if (!asciiText.value) return
  const done = () => {
    copied.value = true
    setTimeout(() => {
      copied.value = false
    }, 1800)
  }
  if (navigator.clipboard && navigator.clipboard.writeText) {
    navigator.clipboard.writeText(asciiText.value).then(done).catch(() => {
      error.value = '复制失败,请手动选中文本复制'
    })
  } else {
    error.value = '当前浏览器不支持剪贴板 API,请手动选中文本复制'
  }
}

function baseName() {
  const n = fileName.value || 'ascii'
  const dot = n.lastIndexOf('.')
  return dot > 0 ? n.slice(0, dot) : n
}

function downloadBlob(blob, name) {
  const url = URL.createObjectURL(blob)
  const a = document.createElement('a')
  a.href = url
  a.download = name
  document.body.appendChild(a)
  a.click()
  document.body.removeChild(a)
  setTimeout(() => URL.revokeObjectURL(url), 1000)
}

function exportTxt() {
  if (!asciiText.value) return
  const blob = new Blob([asciiText.value], { type: 'text/plain;charset=utf-8' })
  downloadBlob(blob, baseName() + '-ascii.txt')
}

function exportPng() {
  if (!asciiText.value) return
  const lines = asciiText.value.split('\n')
  const fontSize = pngFont.value
  const fontSpec = fontSize + 'px "Courier New", Consolas, Menlo, monospace'

  const measure = document.createElement('canvas').getContext('2d')
  measure.font = fontSpec
  const cellW = measure.measureText('M').width || fontSize * 0.6
  const cellH = fontSize
  const pad = Math.round(fontSize * 1.5)

  const canvas = document.createElement('canvas')
  canvas.width = Math.max(1, Math.ceil(outCols.value * cellW + pad * 2))
  canvas.height = Math.max(1, Math.ceil(lines.length * cellH + pad * 2))

  const ctx = canvas.getContext('2d')
  if (pngBg.value === 'dark') {
    ctx.fillStyle = '#111418'
    ctx.fillRect(0, 0, canvas.width, canvas.height)
    ctx.fillStyle = '#e6e8eb'
  } else if (pngBg.value === 'light') {
    ctx.fillStyle = '#ffffff'
    ctx.fillRect(0, 0, canvas.width, canvas.height)
    ctx.fillStyle = '#1f2329'
  } else {
    ctx.fillStyle = '#1f2329'
  }

  ctx.font = fontSpec
  ctx.textBaseline = 'top'
  ctx.textAlign = 'left'
  for (let i = 0; i < lines.length; i++) {
    ctx.fillText(lines[i], pad, pad + i * cellH)
  }

  canvas.toBlob((blob) => {
    if (blob) downloadBlob(blob, baseName() + '-ascii.png')
  }, 'image/png')
}

onBeforeUnmount(() => {
  if (timer) clearTimeout(timer)
  if (objectUrl) URL.revokeObjectURL(objectUrl)
})

2.3 效果截图

图片转 ASCII 字符画 效果截图

工具访问地址:https://www.i91tools.com/tools/image-to-ascii