Skip to content

Instantly share code, notes, and snippets.

@FlyInk13
Last active June 28, 2025 16:21
Show Gist options
  • Save FlyInk13/861c085663405bcaba9cd97f0f64047c to your computer and use it in GitHub Desktop.
Save FlyInk13/861c085663405bcaba9cd97f0f64047c to your computer and use it in GitHub Desktop.
Manga Translator userscript with ollama
// ==UserScript==
// @name Manga Translator
// @namespace http://tampermonkey.net/
// @version 2025-06-27
// @description Select text block on image and you LLM translate it!
// @author Flyink13
// @match https://w7.a-sign-of-affection.online/manga/*
// @icon https://www.google.com/s2/favicons?sz=64&domain=googleusercontent.com
// @grant GM_xmlhttpRequest
// @connect *
// ==/UserScript==
(async function() {
'use strict';
// Константы для ollama
const OLLAMA_ENDPOINT = 'http://localhost:11434/api/';
const OLLAMA_MODEL_ID = "gemma3:12b"
const OLLAMA_PROMPT = `Переведи текст на русский язык, отвечай только переведенным текстом или словом ERROR`;
// Добавляем корневой элемент для выделения и переводов
const appRoot = document.createElement('div');
appRoot.classList.add('mt-app-root');
document.body.appendChild(appRoot);
// Добавляем стили
document.head.appendChild(document.createElement("style")).innerHTML = `
.mt-text {
position: absolute;
box-shadow: black 0px 0px 1px 0px;
line-height: 1.1em;
-pointer-events: none;
background: rgb(255, 255, 255, 0.85);
min-height: fit-content;
font-weight: bold;
align-items: center;
border-radius: 3px;
font-weight: bold;
display: flex;
font-family: "Comic Sans MS", "Comic Sans", cursive;
text-align: center;
font-style: italic;
opacity: 0;
transition: opacity linear .5s;
overflow: hidden;
}
.mt-canvas {
position: absolute;
box-shadow: 0 0 1px 1px blue;
pointer-events: none;
background: rgba(0, 0, 255, 0.1);
text-align: center;
display: block;
transition: box-shadow linear .5s;
}
.mt-select[data-status="processing"] .mt-canvas { box-shadow: 0 0 1px 1px cyan; }
.mt-select[data-status="wait"] .mt-canvas { box-shadow: 0 0 1px 1px orange; }
.mt-select[data-status="error"] .mt-canvas { box-shadow: 0 0 1px 1px red; }
.mt-select[data-status="ok"] .mt-canvas { display: none; }
.mt-select[data-status="ok"] .mt-text { opacity: 1; }
.mt-select[data-status="ok"] .mt-text.mt-hidden { opacity: 0.0; }
`;
// Вычисляет размер текста чтобы максимально заполнить блок
function adjustFontSize(element, minFontSize = 5, maxFontSize = 50, step = 1) {
// Получаем размеры контейнера
const containerWidth = parseInt(element.style.width, 10);
const containerHeight = parseInt(element.style.height, 10);
// Сбрасываем размер шрифта
element.style.fontSize = minFontSize + 'px';
// Функция для проверки переполнения
function isOverflowing(currentFontSize) {
console.log(element.scrollWidth > containerWidth, element.scrollHeight > containerHeight, { sw: element.scrollWidth, cw: containerWidth, sh: element.scrollHeight, ch: containerHeight, currentFontSize })
return element.scrollWidth > containerWidth || element.scrollHeight > containerHeight;
}
let currentFontSize = minFontSize;
let lastGoodFontSize = minFontSize;
// Поиск оптимального размера шрифта
while (currentFontSize <= maxFontSize) {
element.style.fontSize = currentFontSize + 'px';
if (isOverflowing(currentFontSize)) {
// Если текст переполняет контейнер, возвращаем последний удачный размер
element.style.fontSize = lastGoodFontSize + 'px';
break;
}
lastGoodFontSize = currentFontSize;
currentFontSize += step;
}
return lastGoodFontSize;
}
// обертка над api расширения делающего запросы
const fetchCors = (url, opts) => {
return new Promise((resolve, reject) => {
GM_xmlhttpRequest({
url: url,
onload: (response) => resolve(response.response),
onerror: (error) => reject(error),
data: opts.body,
...opts,
});
});
}
// Переводит бинарные данные в base64
function blobToBase64(blob) {
return new Promise((resolve, _) => {
const reader = new FileReader();
reader.onloadend = () => resolve(reader.result);
reader.readAsDataURL(blob);
});
}
// Ходим в ollama (LLM) с куском картинки и просим перевод
const extractText = async (imgBase64) => {
const llmResponse = await fetchCors(OLLAMA_ENDPOINT + 'generate', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
responseType: 'json',
body: JSON.stringify({
model: OLLAMA_MODEL_ID,
responseType: 'json',
prompt: OLLAMA_PROMPT,
images: [imgBase64.substring(22)],
stream: false
}),
});
console.log(llmResponse);
if (!llmResponse.response) return;
return llmResponse.response;
}
// Создаем новое изображение
const createProxyImage = (imgBase64) => {
return new Promise((resolve, reject) => {
const proxyImage = new Image();
proxyImage.onload = async () => resolve(proxyImage);
proxyImage.onerror = async () => reject(proxyImage);
proxyImage.src = imgBase64;
})
}
// Функция добавляющая возможность выделения у картинки
const addSelector = (img) => {
// Отменяет стандартное действи и всплытие события
const preventDefault = (event) => {
event.preventDefault();
event.stopPropagation();
}
// Содаем обработчик события
img.onmousedown = (event) => {
// Выделение только основной клавишей мыши
if (event.button !== 0) return;
// Создаем canvas чтобы вырезать кусок картинки
const imgSelectEl = document.createElement('div');
const canvas = document.createElement('canvas');
const ctx = canvas.getContext('2d');
// Блок для текста
const textEl = document.createElement('div');
// Откуда начали выделение
const imgSelection = {
selection: true,
startY: event.pageY,
startX: event.pageX,
canvas,
selectionImage: img,
};
textEl.onclick = () => {
textEl.classList.toggle('mt-hidden');
}
// Обновление размера выделение с учетом того, что выделять могут в обратную сторону
const update = (el, x, y) => {
el.width = Math.abs(imgSelection.startX - x);
el.height = Math.abs(imgSelection.startY - y);
el.style.left = Math.min(imgSelection.startX, x) + 'px';
el.style.top = Math.min(imgSelection.startY, y) + 'px';
el.style.width = el.width + 'px';
el.style.height = el.height + 'px';
}
// Добавляем элементы на страницу и стили для них
textEl.classList.add('mt-text');
canvas.classList.add('mt-canvas');
imgSelectEl.classList.add('mt-select');
imgSelectEl.appendChild(canvas);
imgSelectEl.appendChild(textEl);
appRoot.appendChild(imgSelectEl);
// Обнавляем размер и отменяем стандарное действие
update(canvas, event.pageX, event.pageY);
preventDefault(event);
img.onclick = preventDefault;
// Обработчик перемещения мыши
img.onmousemove = (event) => {
if (!imgSelection.selection) return;
update(canvas, event.pageX, event.pageY);
preventDefault(event);
}
// Обработчик отпускания мыши (законченного выделения)
img.onmouseup = async (event) => {
preventDefault(event);
imgSelection.selection = false;
// Обновляем размеры блоков
update(canvas, event.pageX, event.pageY);
update(textEl, event.pageX, event.pageY);
imgSelectEl.dataset.status = 'processing';
// Расчитываем откуда и куда копируем
const sx = Math.min(imgSelection.startX, event.pageX) - img.offsetLeft;
const sy = Math.min(imgSelection.startY, event.pageY) - img.offsetTop;
const dw = Math.abs(imgSelection.startX - event.pageX);
const dh = Math.abs(imgSelection.startY - event.pageY);
// Вычисляем "zoom" картинки
const iratio = (img.clientWidth / img.naturalWidth);
// Скачиваем картинку и получаем ее base64, так как обработке будут мешать контентные политики сайта.
const imgData = await fetchCors(img.src, { responseType: 'blob' });
const imgBase64 = await blobToBase64(imgData);
const proxyImage = await createProxyImage(imgBase64);
// Вставляем выделенный кусок картинки в canvas
ctx.drawImage(proxyImage, sx / iratio, sy / iratio, dw / iratio, dh / iratio, 0, 0, dw, dh);
// Получаем base64 выделенного куска картинки
const imageSrcBase64 = canvas.toDataURL("image/png");
// Отпавляем в LLM
imgSelectEl.dataset.status = 'wait';
const text = await extractText(imageSrcBase64).catch(e => "ERROR");
// Если LLM не смог - выходим
if (text === "ERROR") {
imgSelectEl.dataset.status = 'error';
return;
};
// Если смог показываем блок с текстом, скрываем канвас и подгоняем размер текста
textEl.textContent = text;
imgSelectEl.dataset.status = 'ok';
adjustFontSize(textEl);
}
}
}
// Ищем все страницы манги на сайте
document.querySelectorAll('.separator img').forEach(addSelector);
})();
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment