Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 10 additions & 1 deletion docs/docs/features/import.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -78,9 +78,10 @@ Import is supported from any of the following:
- [Shared projects](./share.mdx)
- Raw code
- Code in web page DOM
- Projects shared in official playgrounds of [TypeScript](https://www.typescriptlang.org/play) and [Vue](https://play.vuejs.org/)
- Local file(s)
- Code in zip file (Local or URL)
- Code in image - OCR (Local or URL)
- Projects shared in official playgrounds of [TypeScript](https://www.typescriptlang.org/play) and [Vue](https://play.vuejs.org/)
- [Exported project JSON](./export.mdx) (single project and bulk import)

Import sources are identified by URL patterns (e.g. origin, pathname and extension).
Expand Down Expand Up @@ -175,6 +176,14 @@ Currently, CodePen API does not allow directly importing code from Pens. However

**Note:** External resources (styles/scripts) are not exported with source code in zip file export of CodePen. However, export to GitHub gist does export these. So if a Pen with external resources exported as zip file is not imported properly, try exporting to GitHub gist or manually add the [external resources](./external-resources.mdx).

## Import Code from Image (OCR)

Code can be extracted from images (local or via URL) using [Tesseract.js](https://github.com/naptha/tesseract.js), a library for Optical Character Recognition (OCR).
To ensure accurate identification, the text in the image should be clear, have high contrast against the background, and be free from unrelated text.
Language detection is performed using [highlight.js](https://highlightjs.readthedocs.io/en/latest/api.html#highlightauto), which makes its best guess based on the content.

Best results are obtained when the image is generated using LiveCodes "[Code to Image](./code-to-image.mdx)" feature.

## Import Exported LiveCodes Projects

A [single project exported as JSON](./export.mdx#exporting-a-single-project) can be imported in the same or a different device from the import screen under the tab "Import Project JSON". The JSON file can be supplied as a local file upload or from a URL.
Expand Down
47 changes: 32 additions & 15 deletions src/livecodes/UI/code-to-image.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ import {
loadScript,
loadStylesheet,
} from '../utils';
import { colorisBaseUrl, htmlToImageUrl } from '../vendors';
import { colorisBaseUrl, htmlToImageUrl, metaPngUrl } from '../vendors';

type PreviewEditorOptions = Pick<
EditorOptions,
Expand Down Expand Up @@ -75,7 +75,7 @@ export const createCodeToImageUI = async ({
deps: {
createEditor: (options: PreviewEditorOptions) => Promise<CodeEditor>;
getFormatFn: () => Promise<FormatFn>;
getShareUrl: (config: Partial<Config>) => Promise<string>;
getShareUrl: (config: Partial<Config>, shortUrl?: boolean) => Promise<string>;
getSavedPreset: () => Partial<Preset> | undefined;
savePreset: (preset: Partial<Preset>) => void;
};
Expand Down Expand Up @@ -214,6 +214,7 @@ export const createCodeToImageUI = async ({
const initializeEditor = async (options: Preset) => {
const ed = await deps.createEditor(getEditorOptions(options));
if (ed.getValue().trim() === '') {
editorId = 'script';
ed.setLanguage('tsx', defaultCode);
}
deps.getFormatFn().then((fn) => {
Expand Down Expand Up @@ -306,17 +307,14 @@ export const createCodeToImageUI = async ({
};
updateWatermark(currentUrl);

const getCodeConfig = (): Partial<Config> => {
const language = editor.getLanguage();
return {
title: windowControls.querySelector('#code-to-img-title')!.textContent || '',
activeEditor: editorId,
[editorId]: {
language,
content: editor.getValue(),
},
};
};
const getCodeConfig = (): Partial<Config> => ({
title: windowControls.querySelector('#code-to-img-title')!.textContent || '',
activeEditor: editorId,
[editorId]: {
language: editor.getLanguage(),
content: editor.getValue(),
},
});
let cachedConfig: Partial<Config> | undefined;

let formData: Preset;
Expand Down Expand Up @@ -424,7 +422,7 @@ export const createCodeToImageUI = async ({
const newConfig = getCodeConfig();
if (formData.watermark && JSON.stringify(cachedConfig) !== JSON.stringify(newConfig)) {
cachedConfig = newConfig;
const url = await deps.getShareUrl(newConfig);
const url = await deps.getShareUrl(newConfig, /* shortUrl = */ true);
updateWatermark(url);
}
};
Expand All @@ -449,6 +447,7 @@ export const createCodeToImageUI = async ({
eventsManager.addEventListener(window, 'resize', () => adjustSize(getFormData(), true));

const htmlToImagePromise = loadScript(htmlToImageUrl, 'htmlToImage');
const metaPngPromise = loadScript(metaPngUrl, 'MetaPNG');

const getImageUrl = async () => {
const htmlToImage: any = await htmlToImagePromise;
Expand All @@ -465,7 +464,7 @@ export const createCodeToImageUI = async ({
svg: 'toSvg',
};

return htmlToImage[methodNames[formData.format] || 'toPng'](container, {
let dataUrl = await htmlToImage[methodNames[formData.format] || 'toPng'](container, {
quality: 1,
width: width * scale,
height: height * scale,
Expand All @@ -477,6 +476,24 @@ export const createCodeToImageUI = async ({
height: `${height}px`,
},
});

if (formData.format === 'png') {
try {
const metaPng: any = await metaPngPromise;
const newConfig = getCodeConfig();
let url: string | undefined;
if (formData.watermark && JSON.stringify(cachedConfig) === JSON.stringify(newConfig)) {
url = watermark.innerText.trim();
} else {
url = await deps.getShareUrl(newConfig, formData.watermark);
}
dataUrl = metaPng.addMetadataFromBase64DataURI(dataUrl, 'LiveCodes URL', url);
} catch {
// could not add PNG metadata
}
}

return dataUrl;
};

const saveBtn = codeToImageContainer.querySelector<HTMLButtonElement>('#code-to-img-save-btn')!;
Expand Down
5 changes: 4 additions & 1 deletion src/livecodes/UI/import.ts
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,7 @@ export const createImportUI = ({
e.preventDefault();
const buttonText = importButton.innerHTML;
importButton.innerHTML = window.deps.translateString('generic.loading', 'Loading...');
notifications.info(window.deps.translateString('generic.loading', 'Loading...'));
importButton.disabled = true;
const importInput = getUrlImportInput(importContainer);
const url = importInput.value;
Expand Down Expand Up @@ -111,7 +112,7 @@ export const createImportUI = ({
const codeImportInput = getCodeImportInput(importContainer);
eventsManager.addEventListener(codeImportInput, 'change', () => {
if (!codeImportInput.files?.length) return;

notifications.info(window.deps.translateString('generic.loading', 'Loading...'));
importFromFiles(codeImportInput.files, populateConfig, eventsManager)
.then(loadConfig)
.then(modal.close)
Expand All @@ -126,6 +127,7 @@ export const createImportUI = ({
e.preventDefault();
const buttonText = importJsonUrlButton.innerHTML;
importJsonUrlButton.innerHTML = window.deps.translateString('generic.loading', 'Loading...');
notifications.info(window.deps.translateString('generic.loading', 'Loading...'));
importJsonUrlButton.disabled = true;
const importInput = getImportJsonUrlInput(importContainer);
const url = importInput.value;
Expand Down Expand Up @@ -242,6 +244,7 @@ export const createImportUI = ({

const fileInput = getImportFileInput(importContainer);
eventsManager.addEventListener(fileInput, 'change', () => {
notifications.info(window.deps.translateString('generic.loading', 'Loading...'));
loadFile<Config>(fileInput)
.then(loadConfig)
.then(modal.close)
Expand Down
9 changes: 6 additions & 3 deletions src/livecodes/core.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4279,9 +4279,12 @@ const handleCodeToImage = () => {

const currentUrl = (location.origin + location.pathname).split('/').slice(0, -1).join('/');

const getShareUrl = async (config: Partial<Config>) => {
const param = '/?x=id/' + (await shareService.shareProject(config));
return currentUrl + param;
const getShareUrl = async (config: Partial<Config>, shortUrl = true) => {
if (shortUrl) {
const param = '/?x=id/' + (await shareService.shareProject(config));
return currentUrl + param;
}
return getPlaygroundUrl({ appUrl: currentUrl, config });
};

const codeToImageModule: typeof import('./UI/code-to-image') = await import(
Expand Down
1 change: 1 addition & 0 deletions src/livecodes/html/import.html
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,7 @@
<li>Raw code</li>
<li>Code in web page DOM</li>
<li>Code in zip file</li>
<li>Code in image (OCR)</li>
<li>Official playgrounds<br />(TypeScript and Vue)</li>
</ul>
Please visit the
Expand Down
4 changes: 2 additions & 2 deletions src/livecodes/i18n/locales/en/translation.lokalise.json
Original file line number Diff line number Diff line change
Expand Up @@ -1657,8 +1657,8 @@
"translation": "Bulk import started..."
},
"import.code.desc": {
"notes": "### <tag-1> ###\n<ul />\n\n### <tag-2> ###\n<li />\n\n### <tag-3> ###\n<li />\n\n### <tag-4> ###\n<li />\n\n### <tag-5> ###\n<li />\n\n### <tag-6> ###\n<li />\n\n### <tag-7> ###\n<li />\n\n### <tag-8> ###\n<li />\n\n### <tag-9> ###\n<li />\n\n### <tag-10> ###\n<li />\n\n### <tag-11> ###\n<li />\n\n### <tag-12> ###\n<li />\n\n### <tag-13> ###\n<br />\n\n### <tag-14> ###\n<a href=\"{{DOCS_BASE_URL}}features/import\" target=\"_blank\" rel=\"noopener\" />\n\n",
"translation": "Supported Sources: <tag-1> <tag-2>GitHub gist</tag-2> <tag-3>GitHub file</tag-3> <tag-4>Directory in a GitHub repo</tag-4> <tag-5>Gitlab snippet</tag-5> <tag-6>Gitlab file</tag-6> <tag-7>Directory in a Gitlab repo</tag-7> <tag-8>JS Bin</tag-8> <tag-9>Raw code</tag-9> <tag-10>Code in web page DOM</tag-10> <tag-11>Code in zip file</tag-11> <tag-12>Official playgrounds<tag-13></tag-13>(TypeScript and Vue)</tag-12> </tag-1> Please visit the <tag-14>documentations</tag-14> for details."
"notes": "### <tag-1> ###\n<ul />\n\n### <tag-2> ###\n<li />\n\n### <tag-3> ###\n<li />\n\n### <tag-4> ###\n<li />\n\n### <tag-5> ###\n<li />\n\n### <tag-6> ###\n<li />\n\n### <tag-7> ###\n<li />\n\n### <tag-8> ###\n<li />\n\n### <tag-9> ###\n<li />\n\n### <tag-10> ###\n<li />\n\n### <tag-11> ###\n<li />\n\n### <tag-12> ###\n<li />\n\n### <tag-13> ###\n<li />\n\n### <tag-14> ###\n<br />\n\n### <tag-15> ###\n<a href=\"{{DOCS_BASE_URL}}features/import\" target=\"_blank\" rel=\"noopener\" />\n\n",
"translation": "Supported Sources: <tag-1> <tag-2>GitHub gist</tag-2> <tag-3>GitHub file</tag-3> <tag-4>Directory in a GitHub repo</tag-4> <tag-5>Gitlab snippet</tag-5> <tag-6>Gitlab file</tag-6> <tag-7>Directory in a Gitlab repo</tag-7> <tag-8>JS Bin</tag-8> <tag-9>Raw code</tag-9> <tag-10>Code in web page DOM</tag-10> <tag-11>Code in zip file</tag-11> <tag-12>Code in image (OCR)</tag-12> <tag-13>Official playgrounds<tag-14></tag-14>(TypeScript and Vue)</tag-13> </tag-1> Please visit the <tag-15>documentations</tag-15> for details."
},
"import.code.fromFile": {
"notes": "",
Expand Down
2 changes: 1 addition & 1 deletion src/livecodes/i18n/locales/en/translation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -647,7 +647,7 @@ const translation = {
started: 'Bulk import started...',
},
code: {
desc: 'Supported Sources: <1> <2>GitHub gist</2> <3>GitHub file</3> <4>Directory in a GitHub repo</4> <5>Gitlab snippet</5> <6>Gitlab file</6> <7>Directory in a Gitlab repo</7> <8>JS Bin</8> <9>Raw code</9> <10>Code in web page DOM</10> <11>Code in zip file</11> <12>Official playgrounds<13></13>(TypeScript and Vue)</12> </1> Please visit the <14>documentations</14> for details.',
desc: 'Supported Sources: <1> <2>GitHub gist</2> <3>GitHub file</3> <4>Directory in a GitHub repo</4> <5>Gitlab snippet</5> <6>Gitlab file</6> <7>Directory in a Gitlab repo</7> <8>JS Bin</8> <9>Raw code</9> <10>Code in web page DOM</10> <11>Code in zip file</11> <12>Code in image (OCR)</12> <13>Official playgrounds<14></14>(TypeScript and Vue)</13> </1> Please visit the <15>documentations</15> for details.',
fromFile: 'Import local files',
fromURL: 'Import from URL',
heading: 'Import Code',
Expand Down
10 changes: 9 additions & 1 deletion src/livecodes/import/files.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import type { ContentConfig, EventsManager } from '../models';
import { importFromImage } from './image';
import type { SourceFile, populateConfig as populateConfigFn } from './utils';
import { importFromZip } from './zip';

Expand Down Expand Up @@ -41,11 +42,18 @@ export const importFromFiles = async (
});

const loadZipFile = (files: FileList) => importFromZip(files[0], populateConfig);
const loadImage = (files: FileList) => importFromImage(files[0]);

if (!files?.length) return {};

const getConfigFromFiles =
files?.length === 1 && files[0].name.endsWith('.zip') ? loadZipFile : loadFiles;
files?.length > 1
? loadFiles
: files[0].name.endsWith('.zip')
? loadZipFile
: files[0].type.startsWith('image/') && files[0].type !== 'image/svg+xml'
? loadImage
: loadFiles;

return getConfigFromFiles(files);
};
153 changes: 153 additions & 0 deletions src/livecodes/import/image.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,153 @@
import { detectLanguage, getLanguageByAlias, getLanguageEditorId, languages } from '../languages';
import type { ContentConfig } from '../models';
import { blobToBase64, loadScript } from '../utils/utils';
import { metaPngUrl, tesseractUrl } from '../vendors';
import { importCompressedCode } from './code';
import { importProject } from './project-id';

let Tesseract:
| {
createWorker: (lang: string) => Promise<{
recognize: (blob: Blob) => Promise<{ data: { text: string } }>;
terminate: () => void;
}>;
}
| undefined;

const ocr = async (image: Blob) => {
Tesseract = Tesseract ?? (await import(tesseractUrl)).default;
if (!Tesseract) return '';
const worker = await Tesseract.createWorker('eng');
const ret = await worker.recognize(image);
worker.terminate();
return ret.data.text;
};

/**
* detect images created by LiveCodes "Code to Image" with share URL
*/
const getConfigFromShareUrl = (text: string, isShareUrl = false) => {
const shareUrlPattern = /\?x=(id\/\S{11,20})/g;
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

To make the detection more accurate, would it be beneficial to check for a base URL or similar prefix here?

Copy link
Copy Markdown
Collaborator Author

@hatemhosny hatemhosny May 31, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm not sure.
I want to support:

  • hosted app: livecodes.io
  • permanent URLs: v46.livecodes.io
  • preview URLs: import-image.livecodes.pages.dev
  • self-hosted instances: live-codes.github.io/livecodes (or any self-hosted URL)
  • localhost:8080
  • an app should be able to import images generated by another apps

Do you have a better suggestion?

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Anyway, this should be a lot less relevant after using png meta tags.

let projectId = [...text.matchAll(new RegExp(shareUrlPattern))].at(-1)?.[1];
if (projectId) {
projectId = projectId.replace(/]/g, 'j');
const alphabet = '23456789abcdefghijkmnpqrstuvwxyz';
if (
projectId
.slice('id/'.length)
.split('')
.every((c) => alphabet.includes(c))
) {
return importProject(projectId);
}
}
if (isShareUrl) {
try {
const url = new URL(text.trim());
const code = decodeURIComponent(url.href.split('#config=')[1] || '');
if (code) {
return importCompressedCode(code);
}
} catch {
//
}
}
return null;
};

const cleanUpCode = async (code: string) => {
if (!code?.trim()) return '';
let lines = code.trim().split('\n');
const [firstLine, ...rest] = lines;
const lastLines = lines.slice(-2).join('\n');

const config = await getConfigFromShareUrl(lastLines);
if (config) return config;

// remove first line if it contains window buttons
const buttonCharacters = ['0', 'C', 'N', 'J', 'X', '(', ')', '[', ']', '|'];
const charactersFound = firstLine
.slice(0, 6)
.split('')
.filter((c) => buttonCharacters.includes(c)).length;
const hasButtons = charactersFound > 2 || charactersFound / firstLine.length > 0.6;
if (hasButtons) {
code = rest.join('\n');
}

lines = code.trim().split('\n');

// remove line numbers
if (lines.filter((l) => l.match(/^[0-9]{1,4}\s?/)).length / lines.length > 0.3) {
code = lines.map((l) => l.replace(/^\S{1,4}\s?/, '')).join('\n');
}

code = code.replace(/[β€˜β€™]/g, "'").replace(/[β€œβ€]/g, '"');
return code;
};

export const importFromImage = async (blob: Blob): Promise<Partial<ContentConfig>> => {
try {
const metaPng: any = await loadScript(metaPngUrl, 'MetaPNG');
const arrayBuffer = await blob.arrayBuffer();
const uint8Array = new Uint8Array(arrayBuffer);
const livecodesUrl = metaPng.getMetadata(uint8Array, 'LiveCodes URL');
if (livecodesUrl) {
const config = await getConfigFromShareUrl(livecodesUrl, true);
if (config) return config;
}
} catch {
// not PNG or not generated by LiveCodes, continue
}

try {
const text = await ocr(blob);
const content = await cleanUpCode(text);
if (content && typeof content === 'object') {
// config from share url
return content;
}

if (content.trim().length > 3) {
const langs = languages.map((lang) => lang.name);
const detected = await detectLanguage(content, langs);
detected.language = getLanguageByAlias(detected.language) || detected.language;
detected.secondBest = getLanguageByAlias(detected.secondBest) || detected.secondBest;
// language name or filename with extension in image
const langNamesInCode = languages
.filter(
(lang) =>
content.search(new RegExp(`\\b${lang.name}\\b`, 'i')) !== -1 ||
content.search(new RegExp(`\\b${lang.extensions[0]}\\b`, 'i')) !== -1,
)
.map((lang) => lang.name);
const language =
langNamesInCode.find(
(lang) => lang === detected.language || lang === detected.secondBest,
) ??
langNamesInCode[0] ??
detected.language ??
detected.secondBest ??
'html';

const editorId = getLanguageEditorId(language) ?? 'markup';
return {
activeEditor: editorId,
[editorId]: {
language,
content,
},
};
}
} catch {
//
}

// fallback
return {
markup: {
language: 'html',
content: `<img src="${await blobToBase64(blob)}" alt="image" />`,
},
};
};
2 changes: 1 addition & 1 deletion src/livecodes/import/project-id.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { shareService } from '../services';

export const importProject = (url: string) => {
const id = url.slice(3);
const id = url.slice('id/'.length);
return shareService.getProject(id);
};
Loading