prompt-extractor.user.js

이미지로부터 Exif 정보를 추출해 사용자에게 보여줍니다

目前為 2023-02-27 提交的版本,檢視 最新版本

// ==UserScript==
// @name        prompt-extractor.user.js
// @namespace   https://github.com/toriato/userscripts/prompt-extractor.user.js
// @version     0.1.0
// @description 이미지로부터 Exif 정보를 추출해 사용자에게 보여줍니다
// @author      Sangha Lee <[email protected]>
// @license     MIT
// @match       https://arca.live/b/*
// @run-at      document-start
// @grant       GM_xmlhttpRequest
// @grant       GM_addStyle
// @require     https://cdn.jsdelivr.net/npm/[email protected]/dist/exif-reader.min.js
// ==/UserScript==

GM_addStyle(/*css*/`
@keyframes spin {
  from { transform:rotate(0deg) }
  to { transform:rotate(360deg) }
}

figure.params {
  margin: 0;
  position: relative;
  display: table;
}
figure.params img {
  max-width: 100%;
}

/* 우측 상단 상태 아이콘 */
figure.params:not([data-params=""])::after {
  position: absolute;
  right: 0;
  top: 0;
  margin: .5em;
  font-size: 2rem;
  text-shadow: 0 0 4px black;
  content: '❤️'
}
figure.params.loading::after {
  animation: spin 1s infinite linear;
  content: '🌀'
}
figure.params:not(.loading):not([data-params=""]):hover::after {
  display: none;
}

figure.params figcaption {
  transition: transform .25s, opacity .25s;
  transform: scaleY(0);
  transform-origin: top;
  position: absolute;
  left: 0;
  top: 0;
  overflow-y: auto;
  max-height: 50%;
  padding: .5em;
  opacity: 0;
  background-color: rgba(0, 0, 0, 0.5);
  text-align: left;
  pointer-events: none;
}
figure.params:not(.loading):not([data-params=""]):hover figcaption {
  transform: scaleY(1);
  opacity: 1;
  pointer-events: inherit;
}
`)

/**
 * UPNG.js - JS PNG Decoder/Encoder
 * https://github.com/photopea/UPNG.js
 * MIT License
 */
class UPNG {
  static bin = {
    nextZero: (data, p) => {
      while (data[p] != 0) p++
      return p
    },
    readUshort: (buff, p) =>
      (buff[p] << 8) | buff[p + 1],
    writeUshort: (buff, p, n) => {
      buff[p] = (n >> 8) & 255
      buff[p + 1] = n & 255
    },
    readUint: (buff, p) =>
      (buff[p] * (256 * 256 * 256)) + ((buff[p + 1] << 16) | (buff[p + 2] << 8) | buff[p + 3]),
    writeUint: (buff, p, n) => {
      buff[p] = (n >> 24) & 255
      buff[p + 1] = (n >> 16) & 255
      buff[p + 2] = (n >> 8) & 255
      buff[p + 3] = n & 255
    },
    readASCII: (buff, p, l) => {
      let s = ''
      for (let i = 0; i < l; i++)
        s += String.fromCharCode(buff[p + i])
      return s
    },
    writeASCII: (data, p, s) => {
      for (let i = 0; i < s.length; i++)
        data[p + i] = s.charCodeAt(i)
    },
    readBytes: (buff, p, l) => {
      const arr = []
      for (let i = 0; i < l; i++)
        arr.push(buff[p + i]);
      return arr
    },
    pad: (n) =>
      n.length < 2 ? '0' + n : n,
    readUTF8: function (buff, p, l) {
      let s = ''
      let ns;
      for (var i = 0; i < l; i++) s += "%" + UPNB.bin.pad(buff[p + i].toString(16));
      try { ns = decodeURIComponent(s); }
      catch (e) { return UPNG.bin.readASCII(buff, p, l); }
      return ns;
    }
  }

  static magicNumbers = [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]

  static decode(buff) {
    const bin = UPNG.bin
    const data = new Uint8Array(buff)
    const texts = {}

    for (let i = 0; i < 8; i++) {
      if (data[i] !== UPNG.magicNumbers[i]) {
        throw Error('Input file is not a PNG')
      }
    }

    let offset = 8
    while (offset < data.length) {
      const len = bin.readUint(data, offset); offset += 4;
      const type = bin.readASCII(data, offset, 4); offset += 4;

      if (type == 'tEXt') {
        const nz = bin.nextZero(data, offset);
        const key = bin.readASCII(data, offset, nz - offset);
        const textLen = offset + len - nz - 1;
        texts[key] = bin.readASCII(data, nz + 1, textLen)
      }

      offset += len + 4;
    }

    return texts
  }
}

/**
 * @param {UIEvent} event
 */
function onLoad(event) {
  /** @type {HTMLImageElement} */
  const node = event.target

  // 작은 이미지는 메타데이터 확인하지 않기
  const rect = node.getBoundingClientRect()
  if (rect.width < 128 || rect.height < 128) {
    return
  }

  let src = new URL(node.src)

  // 아카라이브에선 원본 이미지에만 Exif 데이터가 존재함 
  if (src.host.endsWith('namu.la')) {
    src.searchParams.set('type', 'orig')
  }

  const $figure = document.createElement('figure')
  $figure.classList.add('params', 'loading')
  $figure.innerHTML = /*html*/`
    ${node.closest('p').innerHTML}
    <figcaption></figcaption>
  `

  node.replaceWith($figure)

  // Exif 로부터 파라미터 문자열 가져오기
  let params = ''

  new Promise((resolve, reject) => {
    GM_xmlhttpRequest({
      url: src.toString(),
      responseType: 'arraybuffer',
      onload: resolve,
      onerror: reject
    })
  })
    .then(res => {
      const headers = Object.fromEntries(
        res.responseHeaders
          .split(/\r?\n/)
          .map(v => {
            const [key, value] = v.split(':', 2).map(v => v.trim())
            return [key.toLowerCase(), value]
          })
      )

      const contentType = headers['content-type']
      switch (contentType) {
        case 'image/png':
          const texts = UPNG.decode(res.response)
          params = texts['parameters'] ?? ''
          break

        default:
          // 반환 받은 파일이 이미지가 아니라면 무시하기
          if (!contentType.startsWith('image/')) {
            return
          }

          try {
            const tags = ExifReader.load(res.response)

            if (tags?.UserComment?.value) {
              params = String.fromCharCode(
                // 첫 8바이트는 인코딩 타입이므로 디코딩 할 필요 없음
                // https://www.awaresystems.be/imaging/tiff/tifftags/privateifd/exif/usercomment.html
                ...tags.UserComment.value.slice(8).filter(v => v !== 0)
              )
            }

          } catch (err) {
            // 메타데이터가 존재하지 않는 이미지라면 무시하기
            if (err.name !== 'MetadataMissingError') {
              throw err
            }
          }
      }
    })
    .finally(() => {
      $figure.classList.remove('loading')
      $figure.dataset.params = params
      $figure.querySelector('figcaption').innerHTML = params
    })
}

const observer = new MutationObserver(mutations => {
  for (let mutation of mutations) {
    for (let node of mutation.addedNodes) {
      // 노드가 이미지 태그가 아니라면 무시하기
      if (!(node instanceof HTMLImageElement)) {
        continue
      }

      node.addEventListener('load', onLoad)
    }
  }
})

observer.observe(
  document,
  {
    attributes: true,
    childList: true,
    subtree: true
  }
)