509 lines
14 KiB
JavaScript
509 lines
14 KiB
JavaScript
|
|
const {
|
|||
|
|
Notice,
|
|||
|
|
Plugin,
|
|||
|
|
PluginSettingTab,
|
|||
|
|
Setting,
|
|||
|
|
TFile,
|
|||
|
|
} = require("obsidian");
|
|||
|
|
|
|||
|
|
const DEFAULT_SETTINGS = {
|
|||
|
|
codeBlockLanguage: "text",
|
|||
|
|
includeMetadataKey: true,
|
|||
|
|
skipExistingPromptBlocks: true,
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
const IMAGE_EMBED_RE = /!\[[^\]]*]\(([^)]+)\)|!\[\[([^|\]\n]+)(?:\|[^\]\n]*)?]]/g;
|
|||
|
|
const PROMPT_BLOCK_MARKER = "<!-- exif-prompt-extractor -->";
|
|||
|
|
|
|||
|
|
module.exports = class ExifPromptPlugin extends Plugin {
|
|||
|
|
async onload() {
|
|||
|
|
this.settings = Object.assign({}, DEFAULT_SETTINGS, await this.loadData());
|
|||
|
|
|
|||
|
|
this.addRibbonIcon("image-plus", "Extract image prompt", async () => {
|
|||
|
|
await this.extractForActiveImage();
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
this.addCommand({
|
|||
|
|
id: "extract-prompt-for-image-at-cursor",
|
|||
|
|
name: "Extract prompt for image at cursor",
|
|||
|
|
icon: "image-plus",
|
|||
|
|
hotkeys: [{ modifiers: ["Mod", "Alt"], key: "p" }],
|
|||
|
|
editorCallback: async (editor, view) => {
|
|||
|
|
await this.extractForImageAtCursor(editor, view);
|
|||
|
|
},
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
this.addCommand({
|
|||
|
|
id: "extract-prompts-for-all-images-in-note",
|
|||
|
|
name: "Extract prompts for all images in note",
|
|||
|
|
icon: "images",
|
|||
|
|
editorCallback: async (editor, view) => {
|
|||
|
|
await this.extractAllInEditor(editor, view);
|
|||
|
|
},
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
this.addSettingTab(new ExifPromptSettingTab(this.app, this));
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
async saveSettings() {
|
|||
|
|
await this.saveData(this.settings);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
async extractForActiveImage() {
|
|||
|
|
const view = this.app.workspace.getActiveViewOfType(require("obsidian").MarkdownView);
|
|||
|
|
if (!view) {
|
|||
|
|
new Notice("Open a Markdown note first.");
|
|||
|
|
return;
|
|||
|
|
}
|
|||
|
|
await this.extractForImageAtCursor(view.editor, view);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
async extractForImageAtCursor(editor, view) {
|
|||
|
|
const lineNumber = editor.getCursor().line;
|
|||
|
|
const match = findNearestImageEmbed(editor, lineNumber);
|
|||
|
|
if (!match) {
|
|||
|
|
new Notice("No image embed found near the cursor.");
|
|||
|
|
return;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const result = await this.extractPromptFromLink(match.link, view.file);
|
|||
|
|
if (!result.prompt) {
|
|||
|
|
new Notice(result.message || "No prompt metadata found.");
|
|||
|
|
return;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
if (this.settings.skipExistingPromptBlocks && hasPromptBlockAfter(editor, match.line)) {
|
|||
|
|
new Notice("Prompt block already exists below this image.");
|
|||
|
|
return;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const block = this.formatPromptBlock(result.prompt, result.label);
|
|||
|
|
editor.replaceRange("\n" + block, { line: match.line + 1, ch: 0 });
|
|||
|
|
new Notice("Prompt inserted below the image.");
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
async extractAllInEditor(editor, view) {
|
|||
|
|
const embeds = findAllImageEmbeds(editor.getValue());
|
|||
|
|
if (!embeds.length) {
|
|||
|
|
new Notice("No image embeds found in this note.");
|
|||
|
|
return;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
let inserted = 0;
|
|||
|
|
for (let index = embeds.length - 1; index >= 0; index--) {
|
|||
|
|
const embed = embeds[index];
|
|||
|
|
if (this.settings.skipExistingPromptBlocks && hasPromptBlockAfter(editor, embed.line)) {
|
|||
|
|
continue;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const result = await this.extractPromptFromLink(embed.link, view.file);
|
|||
|
|
if (!result.prompt) {
|
|||
|
|
continue;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
editor.replaceRange(
|
|||
|
|
"\n" + this.formatPromptBlock(result.prompt, result.label),
|
|||
|
|
{ line: embed.line + 1, ch: 0 }
|
|||
|
|
);
|
|||
|
|
inserted++;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
new Notice(inserted ? `Inserted ${inserted} prompt block(s).` : "No new prompt metadata found.");
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
async extractPromptFromLink(link, sourceFile) {
|
|||
|
|
const cleanLink = normalizeImageLink(link);
|
|||
|
|
const file = this.app.metadataCache.getFirstLinkpathDest(cleanLink, sourceFile ? sourceFile.path : "");
|
|||
|
|
|
|||
|
|
if (!(file instanceof TFile)) {
|
|||
|
|
return { prompt: null, message: `Image not found: ${cleanLink}` };
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const extension = file.extension.toLowerCase();
|
|||
|
|
if (!["png", "jpg", "jpeg", "webp"].includes(extension)) {
|
|||
|
|
return { prompt: null, message: `Unsupported image type: ${file.extension}` };
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const buffer = await this.app.vault.readBinary(file);
|
|||
|
|
const bytes = new Uint8Array(buffer);
|
|||
|
|
const metadata = extractImageMetadata(bytes, extension);
|
|||
|
|
const picked = pickPrompt(metadata);
|
|||
|
|
|
|||
|
|
return {
|
|||
|
|
prompt: picked ? picked.value : null,
|
|||
|
|
label: picked ? picked.key : null,
|
|||
|
|
message: picked ? null : `No prompt metadata found in ${file.name}.`,
|
|||
|
|
};
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
formatPromptBlock(prompt, label) {
|
|||
|
|
const language = this.settings.codeBlockLanguage.trim() || "text";
|
|||
|
|
const header = this.settings.includeMetadataKey && label ? `Prompt metadata: ${label}\n\n` : "";
|
|||
|
|
return `${PROMPT_BLOCK_MARKER}\n\`\`\`${language}\n${header}${prompt.trim()}\n\`\`\`\n`;
|
|||
|
|
}
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
class ExifPromptSettingTab extends PluginSettingTab {
|
|||
|
|
constructor(app, plugin) {
|
|||
|
|
super(app, plugin);
|
|||
|
|
this.plugin = plugin;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
display() {
|
|||
|
|
const { containerEl } = this;
|
|||
|
|
containerEl.empty();
|
|||
|
|
|
|||
|
|
new Setting(containerEl)
|
|||
|
|
.setName("Code block language")
|
|||
|
|
.setDesc("Language label for inserted fenced code blocks.")
|
|||
|
|
.addText((text) => text
|
|||
|
|
.setPlaceholder("text")
|
|||
|
|
.setValue(this.plugin.settings.codeBlockLanguage)
|
|||
|
|
.onChange(async (value) => {
|
|||
|
|
this.plugin.settings.codeBlockLanguage = value || "text";
|
|||
|
|
await this.plugin.saveSettings();
|
|||
|
|
}));
|
|||
|
|
|
|||
|
|
new Setting(containerEl)
|
|||
|
|
.setName("Include metadata key")
|
|||
|
|
.setDesc("Add the metadata field name at the top of inserted prompt blocks.")
|
|||
|
|
.addToggle((toggle) => toggle
|
|||
|
|
.setValue(this.plugin.settings.includeMetadataKey)
|
|||
|
|
.onChange(async (value) => {
|
|||
|
|
this.plugin.settings.includeMetadataKey = value;
|
|||
|
|
await this.plugin.saveSettings();
|
|||
|
|
}));
|
|||
|
|
|
|||
|
|
new Setting(containerEl)
|
|||
|
|
.setName("Skip existing prompt blocks")
|
|||
|
|
.setDesc("Avoid inserting another block when this plugin already added one below the image.")
|
|||
|
|
.addToggle((toggle) => toggle
|
|||
|
|
.setValue(this.plugin.settings.skipExistingPromptBlocks)
|
|||
|
|
.onChange(async (value) => {
|
|||
|
|
this.plugin.settings.skipExistingPromptBlocks = value;
|
|||
|
|
await this.plugin.saveSettings();
|
|||
|
|
}));
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function findNearestImageEmbed(editor, lineNumber) {
|
|||
|
|
const maxDistance = 8;
|
|||
|
|
for (let distance = 0; distance <= maxDistance; distance++) {
|
|||
|
|
for (const candidateLine of uniqueLineCandidates(lineNumber, distance, editor.lineCount())) {
|
|||
|
|
const line = editor.getLine(candidateLine);
|
|||
|
|
const matches = parseImageEmbedsFromLine(line, candidateLine);
|
|||
|
|
if (matches.length) {
|
|||
|
|
return matches[0];
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
return null;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function uniqueLineCandidates(baseLine, distance, lineCount) {
|
|||
|
|
const candidates = distance === 0 ? [baseLine] : [baseLine - distance, baseLine + distance];
|
|||
|
|
return [...new Set(candidates)].filter((line) => line >= 0 && line < lineCount);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function findAllImageEmbeds(markdown) {
|
|||
|
|
const lines = markdown.split("\n");
|
|||
|
|
return lines.flatMap((line, index) => parseImageEmbedsFromLine(line, index));
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function parseImageEmbedsFromLine(line, lineNumber) {
|
|||
|
|
const matches = [];
|
|||
|
|
IMAGE_EMBED_RE.lastIndex = 0;
|
|||
|
|
let match;
|
|||
|
|
while ((match = IMAGE_EMBED_RE.exec(line)) !== null) {
|
|||
|
|
matches.push({
|
|||
|
|
line: lineNumber,
|
|||
|
|
link: match[1] || match[2],
|
|||
|
|
from: match.index,
|
|||
|
|
to: match.index + match[0].length,
|
|||
|
|
});
|
|||
|
|
}
|
|||
|
|
return matches;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function hasPromptBlockAfter(editor, imageLine) {
|
|||
|
|
const lookAhead = Math.min(editor.lineCount(), imageLine + 8);
|
|||
|
|
for (let line = imageLine + 1; line < lookAhead; line++) {
|
|||
|
|
const text = editor.getLine(line);
|
|||
|
|
if (text.includes(PROMPT_BLOCK_MARKER)) {
|
|||
|
|
return true;
|
|||
|
|
}
|
|||
|
|
if (parseImageEmbedsFromLine(text, line).length) {
|
|||
|
|
return false;
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
return false;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function normalizeImageLink(link) {
|
|||
|
|
return decodeURIComponent(link)
|
|||
|
|
.split("#")[0]
|
|||
|
|
.split("?")[0]
|
|||
|
|
.trim();
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function extractImageMetadata(bytes, extension) {
|
|||
|
|
if (extension === "png") {
|
|||
|
|
return extractPngMetadata(bytes);
|
|||
|
|
}
|
|||
|
|
return extractStringMetadata(bytes);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function extractPngMetadata(bytes) {
|
|||
|
|
const metadata = {};
|
|||
|
|
if (!hasPngSignature(bytes)) {
|
|||
|
|
return extractStringMetadata(bytes);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
let offset = 8;
|
|||
|
|
while (offset + 12 <= bytes.length) {
|
|||
|
|
const length = readUInt32(bytes, offset);
|
|||
|
|
const type = ascii(bytes.subarray(offset + 4, offset + 8));
|
|||
|
|
const dataStart = offset + 8;
|
|||
|
|
const dataEnd = dataStart + length;
|
|||
|
|
if (dataEnd + 4 > bytes.length) {
|
|||
|
|
break;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
if (type === "tEXt") {
|
|||
|
|
readTextChunk(bytes.subarray(dataStart, dataEnd), metadata);
|
|||
|
|
} else if (type === "iTXt") {
|
|||
|
|
readInternationalTextChunk(bytes.subarray(dataStart, dataEnd), metadata);
|
|||
|
|
} else if (type === "zTXt") {
|
|||
|
|
readCompressedTextChunk(bytes.subarray(dataStart, dataEnd), metadata);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
offset = dataEnd + 4;
|
|||
|
|
if (type === "IEND") {
|
|||
|
|
break;
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
return Object.keys(metadata).length ? metadata : extractStringMetadata(bytes);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function readTextChunk(data, metadata) {
|
|||
|
|
const separator = data.indexOf(0);
|
|||
|
|
if (separator < 0) {
|
|||
|
|
return;
|
|||
|
|
}
|
|||
|
|
const key = latin1(data.subarray(0, separator));
|
|||
|
|
const value = decodeText(data.subarray(separator + 1));
|
|||
|
|
if (key && value) {
|
|||
|
|
metadata[key] = value;
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function readInternationalTextChunk(data, metadata) {
|
|||
|
|
let cursor = 0;
|
|||
|
|
const keyEnd = data.indexOf(0, cursor);
|
|||
|
|
if (keyEnd < 0) return;
|
|||
|
|
const key = latin1(data.subarray(cursor, keyEnd));
|
|||
|
|
cursor = keyEnd + 1;
|
|||
|
|
const compressionFlag = data[cursor++];
|
|||
|
|
cursor++;
|
|||
|
|
|
|||
|
|
const languageEnd = data.indexOf(0, cursor);
|
|||
|
|
if (languageEnd < 0) return;
|
|||
|
|
cursor = languageEnd + 1;
|
|||
|
|
|
|||
|
|
const translatedEnd = data.indexOf(0, cursor);
|
|||
|
|
if (translatedEnd < 0) return;
|
|||
|
|
cursor = translatedEnd + 1;
|
|||
|
|
|
|||
|
|
let textBytes = data.subarray(cursor);
|
|||
|
|
if (compressionFlag === 1) {
|
|||
|
|
textBytes = inflateBytes(textBytes);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const value = decodeText(textBytes);
|
|||
|
|
if (key && value) {
|
|||
|
|
metadata[key] = value;
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function readCompressedTextChunk(data, metadata) {
|
|||
|
|
const separator = data.indexOf(0);
|
|||
|
|
if (separator < 0 || separator + 2 >= data.length) {
|
|||
|
|
return;
|
|||
|
|
}
|
|||
|
|
const key = latin1(data.subarray(0, separator));
|
|||
|
|
const compressed = data.subarray(separator + 2);
|
|||
|
|
const value = decodeText(inflateBytes(compressed));
|
|||
|
|
if (key && value) {
|
|||
|
|
metadata[key] = value;
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function extractStringMetadata(bytes) {
|
|||
|
|
const text = decodeText(bytes)
|
|||
|
|
.replace(/\u0000/g, "\n")
|
|||
|
|
.replace(/[^\S\r\n]+/g, " ");
|
|||
|
|
|
|||
|
|
const metadata = {};
|
|||
|
|
const patterns = [
|
|||
|
|
["parameters", /parameters[\s:=]+([\s\S]{20,8000}?)(?:\n[A-Z][A-Za-z ]{1,30}:|\n\n\n|$)/i],
|
|||
|
|
["UserComment", /UserComment[\s:=]+([\s\S]{20,8000}?)(?:\n[A-Z][A-Za-z ]{1,30}:|\n\n\n|$)/i],
|
|||
|
|
["ImageDescription", /ImageDescription[\s:=]+([\s\S]{20,8000}?)(?:\n[A-Z][A-Za-z ]{1,30}:|\n\n\n|$)/i],
|
|||
|
|
["prompt", /prompt["'\s:=]+([\s\S]{20,8000}?)(?:negative_prompt|Negative prompt|Steps:|Sampler:|Seed:|$)/i],
|
|||
|
|
];
|
|||
|
|
|
|||
|
|
for (const [key, pattern] of patterns) {
|
|||
|
|
const match = text.match(pattern);
|
|||
|
|
if (match && match[1]) {
|
|||
|
|
metadata[key] = cleanExtractedText(match[1]);
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
return metadata;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function pickPrompt(metadata) {
|
|||
|
|
const preferredKeys = [
|
|||
|
|
"parameters",
|
|||
|
|
"prompt",
|
|||
|
|
"Prompt",
|
|||
|
|
"UserComment",
|
|||
|
|
"ImageDescription",
|
|||
|
|
"Description",
|
|||
|
|
"Comment",
|
|||
|
|
"workflow",
|
|||
|
|
];
|
|||
|
|
|
|||
|
|
for (const key of preferredKeys) {
|
|||
|
|
if (metadata[key]) {
|
|||
|
|
return { key, value: normalizePromptValue(metadata[key]) };
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const fallbackKey = Object.keys(metadata).find((key) => looksLikePrompt(metadata[key]));
|
|||
|
|
return fallbackKey ? { key: fallbackKey, value: normalizePromptValue(metadata[fallbackKey]) } : null;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function normalizePromptValue(value) {
|
|||
|
|
const trimmed = cleanExtractedText(value);
|
|||
|
|
const jsonPrompt = extractPromptFromJson(trimmed);
|
|||
|
|
return jsonPrompt || trimmed;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function extractPromptFromJson(value) {
|
|||
|
|
if (!value.trim().startsWith("{") && !value.trim().startsWith("[")) {
|
|||
|
|
return null;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
try {
|
|||
|
|
const parsed = JSON.parse(value);
|
|||
|
|
const collected = [];
|
|||
|
|
collectPromptStrings(parsed, collected);
|
|||
|
|
return collected.length ? collected.join("\n\n") : null;
|
|||
|
|
} catch (_) {
|
|||
|
|
return null;
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function collectPromptStrings(node, collected) {
|
|||
|
|
if (!node || collected.length >= 12) {
|
|||
|
|
return;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
if (typeof node === "string") {
|
|||
|
|
if (looksLikePrompt(node)) {
|
|||
|
|
collected.push(node.trim());
|
|||
|
|
}
|
|||
|
|
return;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
if (Array.isArray(node)) {
|
|||
|
|
node.forEach((item) => collectPromptStrings(item, collected));
|
|||
|
|
return;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
if (typeof node === "object") {
|
|||
|
|
for (const [key, value] of Object.entries(node)) {
|
|||
|
|
const lower = key.toLowerCase();
|
|||
|
|
if (typeof value === "string" && ["text", "prompt", "positive", "inputs"].some((part) => lower.includes(part))) {
|
|||
|
|
collectPromptStrings(value, collected);
|
|||
|
|
} else if (typeof value === "object") {
|
|||
|
|
collectPromptStrings(value, collected);
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function looksLikePrompt(value) {
|
|||
|
|
if (!value || value.length < 20) {
|
|||
|
|
return false;
|
|||
|
|
}
|
|||
|
|
return /(negative prompt|steps:|sampler:|cfg scale|seed:|masterpiece|cinematic|portrait|photo|prompt)/i.test(value);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function cleanExtractedText(value) {
|
|||
|
|
return value
|
|||
|
|
.replace(/\r\n/g, "\n")
|
|||
|
|
.replace(/\r/g, "\n")
|
|||
|
|
.replace(/[^\x09\x0A\x0D\x20-\x7E\u0400-\u04FF\u2010-\u2027]+/g, " ")
|
|||
|
|
.replace(/[ \t]+\n/g, "\n")
|
|||
|
|
.replace(/\n{4,}/g, "\n\n\n")
|
|||
|
|
.trim();
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function inflateBytes(bytes) {
|
|||
|
|
try {
|
|||
|
|
const zlib = require("zlib");
|
|||
|
|
return new Uint8Array(zlib.inflateSync(Buffer.from(bytes)));
|
|||
|
|
} catch (_) {
|
|||
|
|
return new Uint8Array();
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function hasPngSignature(bytes) {
|
|||
|
|
return bytes.length > 8
|
|||
|
|
&& bytes[0] === 0x89
|
|||
|
|
&& bytes[1] === 0x50
|
|||
|
|
&& bytes[2] === 0x4e
|
|||
|
|
&& bytes[3] === 0x47
|
|||
|
|
&& bytes[4] === 0x0d
|
|||
|
|
&& bytes[5] === 0x0a
|
|||
|
|
&& bytes[6] === 0x1a
|
|||
|
|
&& bytes[7] === 0x0a;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function readUInt32(bytes, offset) {
|
|||
|
|
return ((bytes[offset] << 24) | (bytes[offset + 1] << 16) | (bytes[offset + 2] << 8) | bytes[offset + 3]) >>> 0;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function ascii(bytes) {
|
|||
|
|
return Array.from(bytes, (byte) => String.fromCharCode(byte)).join("");
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function latin1(bytes) {
|
|||
|
|
return new TextDecoder("latin1").decode(bytes).trim();
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function decodeText(bytes) {
|
|||
|
|
const utf8 = new TextDecoder("utf-8", { fatal: false }).decode(bytes);
|
|||
|
|
if (utf8.includes("\u0000")) {
|
|||
|
|
const utf16 = tryDecodeUtf16(bytes);
|
|||
|
|
if (utf16 && countPrintable(utf16) > countPrintable(utf8)) {
|
|||
|
|
return utf16;
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
return utf8;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function tryDecodeUtf16(bytes) {
|
|||
|
|
try {
|
|||
|
|
return new TextDecoder("utf-16le", { fatal: false }).decode(bytes);
|
|||
|
|
} catch (_) {
|
|||
|
|
return "";
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function countPrintable(text) {
|
|||
|
|
return (text.match(/[\wа-яА-Я.,:;!?()[\]{}'" -]/g) || []).length;
|
|||
|
|
}
|