atcoder-piet-image-converter

AtCoderで画像ファイルをPlain PPM形式に変換し、Pietのソースコードとして提出できるようにします。また、Plain PPM形式の提出結果を画像に変換して表示します。

// ==UserScript==
// @name        atcoder-piet-image-converter
// @namespace   https://github.com/dnek
// @version     1.0
// @author      dnek
// @description AtCoderで画像ファイルをPlain PPM形式に変換し、Pietのソースコードとして提出できるようにします。また、Plain PPM形式の提出結果を画像に変換して表示します。
// @description:ja  AtCoderで画像ファイルをPlain PPM形式に変換し、Pietのソースコードとして提出できるようにします。また、Plain PPM形式の提出結果を画像に変換して表示します。
// @homepageURL https://github.com/dnek/atcoder-piet-image-converter
// @match       https://atcoder.jp/contests/*/custom_test*
// @match       https://atcoder.jp/contests/*/submit*
// @match       https://atcoder.jp/contests/*/tasks/*
// @match       https://atcoder.jp/contests/*/submissions/*
// @grant       none
// @license     MIT license
// ==/UserScript==

(async () => {
	'use strict';

	const getEnJa = (en, ja) => LANG === 'en' ? en : ja;

	// convert image to Plain PPM
	if (document.getElementById('editor') !== null) {
		const minifyCodelSize = (rawPixelData, rawWidth, rawHeight) => {
			const arr32 = new Uint32Array(rawPixelData.buffer);
			const checkIsValidCodelSize = (codelSize) => {
				if (rawWidth % codelSize > 0 || rawHeight % codelSize > 0) {
					return false;
				}
				const codeWidth = rawWidth / codelSize;
				const codeHeight = rawHeight / codelSize;
				for (let i = 0; i < codeHeight; i++) {
					for (let j = 0; j < codeWidth; j++) {
						const topLeft = (i * rawWidth + j) * codelSize;
						const topLeftValue = arr32[topLeft];
						for (let k = 0; k < codelSize; k++) {
							for (let l = 0; l < codelSize; l++) {
								if (arr32[topLeft + k * rawWidth + l] !== topLeftValue) {
									return false;
								}
							}
						}
					}
				}
				return true;
			};
			for (let codelSize = Math.min(rawWidth, rawHeight); codelSize > 1; codelSize--) {
				if (!checkIsValidCodelSize(codelSize)) {
					continue;
				}
				const width = rawWidth / codelSize;
				const height = rawHeight / codelSize;
				const minArr32 = new Uint32Array(width * height);
				for (let i = 0; i < height; i++) {
					for (let j = 0; j < width; j++) {
						minArr32[i * width + j] = arr32[(i * rawWidth + j) * codelSize];
					}
				}
				const pixelData = new Uint8ClampedArray(minArr32.buffer);
				return { pixelData, width, height };
			}
			return {
				pixelData: rawPixelData,
				width: rawWidth,
				height: rawHeight
			};
		};

		const convertImageToPlainPpm = async (file) => {
			if (!file) {
				return;
			}

			const bitmap = await createImageBitmap(file);
			const bitmapWidth = bitmap.width;
			const bitmapHeight = bitmap.height;
			const canvas = new OffscreenCanvas(bitmapWidth, bitmapHeight);
			const ctx = canvas.getContext('2d', { alpha: false });
			ctx.drawImage(bitmap, 0, 0);
			const bitmapPixelData = ctx.getImageData(0, 0, bitmapWidth, bitmapHeight).data;
			const { pixelData, width, height } = minifyCodelSize(bitmapPixelData, bitmapWidth, bitmapHeight);
			const maxval = 85;
			const convertedValues = new Uint8Array(256);
			for (let i = 0; i < 256; i++) {
				convertedValues[i] = Math.round(i * maxval / 255);
			}
			const lines = [];
			for (let i = 0; i < height; i++) {
				const arr = [];
				for (let j = 0; j < width; j++) {
					const offset = (i * width + j) * 4;
					for (let k = 0; k < 3; k++) {
						arr.push(convertedValues[pixelData[offset + k]]);
					}
				}
				lines.push(arr.join(' '));
			}
			const sourceCodeStr = `P3${width} ${height}\n${maxval}\n${lines.join('\n')}`;

			ace.edit('editor').setValue(sourceCodeStr, 1);
			document.getElementById('plain-textarea').value = sourceCodeStr;
		};

		const imageFileInput = document.createElement('input');
		imageFileInput.type = 'file';
		imageFileInput.accept = 'image/*';
		imageFileInput.addEventListener('change', async (e) => {
			convertImageToPlainPpm(e.target.files[0]);
		});

		const convertButton = document.createElement('button');
		convertButton.textContent = getEnJa('Convert (Piet)', '変換 (Piet)');
		convertButton.classList.add('btn', 'btn-default', 'btn-sm');

		convertButton.addEventListener('click', (e) => {
			e.preventDefault();
			imageFileInput.click();
		});

		const stopAndPrevent = (e) => {
			e.stopPropagation();
			e.preventDefault();
		};
		convertButton.addEventListener('dragenter', stopAndPrevent, false);
		convertButton.addEventListener('dragover', (e) => {
			stopAndPrevent(e);
			e.dataTransfer.dropEffect = 'copy';
		}, false);
		convertButton.addEventListener('drop', (e) => {
			stopAndPrevent(e);
			convertImageToPlainPpm(e.dataTransfer.files[0]);
		}, false);

		document.querySelector('.editor-buttons').append(convertButton);
	}

	// convert Plain PPM to image
	if (document.getElementById('submission-code') !== null) {
		const testSomeTextContent = (selectors, pattern) => {
			const texts = Array.from(document.querySelectorAll(selectors), (el) => el.textContent);
			return texts.some((text) => pattern.test(text));
		};
		if (!testSomeTextContent('#submission-code ~ h4', getEnJa(/Judge Result/, /ジャッジ結果/))) {
			return;
		}
		if (!testSomeTextContent('table:has(#judge-status) td:not(:has(> a))', /Piet/)) {
			return;
		}

		const sourceCodeStr = ace.edit('submission-code').getValue();
		if (!sourceCodeStr.startsWith('P3')) {
			return;
		}

		const sourceCodeTokens = sourceCodeStr.slice(2).replace(/#.*?(\n|$)/g, '').match(/\S+/g).map(Number);
		const [width, height, maxval] = sourceCodeTokens;
		const convertedValues = new Uint8Array(256);
		for (let i = 0; i < 256; i++) {
			convertedValues[i] = Math.round(i * 255 / maxval);
		}
		const pixelData = new Uint8ClampedArray(width * height * 4);
		for (let i = 0; i < width * height; i++) {
			for (let j = 0; j < 3; j++) {
				pixelData[i * 4 + j] = convertedValues[sourceCodeTokens[3 + i * 3 + j]];
			}
			pixelData[i * 4 + 3] = 255;
		}
		const bitmap = await createImageBitmap(new ImageData(pixelData, width, height));
		const canvas = new OffscreenCanvas(width, height);
		canvas.getContext('bitmaprenderer', { alpha: false }).transferFromImageBitmap(bitmap);
		const blob = await canvas.convertToBlob();

		const previewImg = document.createElement('img');
		previewImg.style.imageRendering = 'pixelated';
		previewImg.src = URL.createObjectURL(blob);

		const headingP = document.createElement('p');
		const titleSpan = document.createElement('span');
		titleSpan.textContent = getEnJa('Preview (Piet)', 'プレビュー (Piet)');
		titleSpan.classList.add('h4');

		const zoomRatioDiv = document.createElement('div');
		let zoomIndex = 0;
		const changeZoomRatio = (newZoomIndex) => {
			zoomIndex = newZoomIndex;
			const zoomRatio = Math.pow(2, zoomIndex);
			previewImg.style.zoom = zoomRatio;
			zoomRatioDiv.textContent = `${getEnJa('zoom', '拡大率')}: ${zoomRatio * 100}%`;
		};
		changeZoomRatio(3);

		const createIconButton = (icon, label) => {
			const iconButton = document.createElement('button');
			iconButton.classList.add('btn', 'btn-default');
			iconButton.ariaLabel = label;
			const iconSpan = document.createElement('span');
			iconSpan.classList.add('glyphicon', icon);
			iconSpan.ariaHidden = true;
			iconButton.append(iconSpan);
			return iconButton;
		};

		const zoomOutButton = createIconButton('glyphicon-zoom-out', 'zoom out');
		zoomOutButton.addEventListener('click', () => changeZoomRatio(zoomIndex - 1));
		const zoomInButton = createIconButton('glyphicon-zoom-in', 'zoom in');
		zoomInButton.addEventListener('click', () => changeZoomRatio(zoomIndex + 1));

		const downloadButton = createIconButton('glyphicon-download-alt', 'download');
		downloadButton.addEventListener('click', () => {
			const linkEl = document.createElement('a');
			const submissionId = location.pathname.split('/').pop();
			linkEl.download = `atcoder-submission-${submissionId}.png`;
			linkEl.href = previewImg.src;
			linkEl.click();
		});

		const panelDiv = document.createElement('div');
		panelDiv.classList.add('panel', 'panel-default');
		panelDiv.style.backgroundColor = '#F5F5F5';
		panelDiv.style.padding = '8px';
		panelDiv.style.overflow = 'auto';
		panelDiv.append(previewImg);

		const expandButton = document.createElement('a');
		expandButton.classList.add('btn-text');
		let isExpanded = true;
		const toggleExpand = () => {
			isExpanded = !isExpanded;
			expandButton.textContent = isExpanded ? getEnJa('Collapse', '折りたたむ') : getEnJa('Expand', '拡げる');
			panelDiv.style.maxHeight = isExpanded ? 'none' : '240px';
		};
		toggleExpand();
		expandButton.addEventListener('click', toggleExpand);

		headingP.append(
			titleSpan,
			' ', zoomOutButton,
			' ', zoomInButton,
			' ', downloadButton,
			' ', expandButton,
			zoomRatioDiv
		);

		document.getElementById('submission-code').after(headingP, panelDiv);
	}
})();