@jungjaehoon/mama-os 0.25.0 → 0.27.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +9 -0
- package/README.md +37 -17
- package/dist/agent/agent-loop.d.ts.map +1 -1
- package/dist/agent/agent-loop.js +8 -0
- package/dist/agent/agent-loop.js.map +1 -1
- package/dist/agent/attachment-text-extractor.d.ts +2 -0
- package/dist/agent/attachment-text-extractor.d.ts.map +1 -0
- package/dist/agent/attachment-text-extractor.js +128 -0
- package/dist/agent/attachment-text-extractor.js.map +1 -0
- package/dist/agent/code-act/constants.d.ts.map +1 -1
- package/dist/agent/code-act/constants.js +17 -2
- package/dist/agent/code-act/constants.js.map +1 -1
- package/dist/agent/code-act/host-bridge.d.ts.map +1 -1
- package/dist/agent/code-act/host-bridge.js +107 -0
- package/dist/agent/code-act/host-bridge.js.map +1 -1
- package/dist/agent/code-act/tool-policy.d.ts +9 -1
- package/dist/agent/code-act/tool-policy.d.ts.map +1 -1
- package/dist/agent/code-act/tool-policy.js +30 -1
- package/dist/agent/code-act/tool-policy.js.map +1 -1
- package/dist/agent/codex-app-server-process.d.ts +1 -0
- package/dist/agent/codex-app-server-process.d.ts.map +1 -1
- package/dist/agent/codex-app-server-process.js +16 -0
- package/dist/agent/codex-app-server-process.js.map +1 -1
- package/dist/agent/drive-tools.d.ts +55 -0
- package/dist/agent/drive-tools.d.ts.map +1 -0
- package/dist/agent/drive-tools.js +263 -0
- package/dist/agent/drive-tools.js.map +1 -0
- package/dist/agent/gateway-tool-executor.d.ts +11 -3
- package/dist/agent/gateway-tool-executor.d.ts.map +1 -1
- package/dist/agent/gateway-tool-executor.js +249 -19
- package/dist/agent/gateway-tool-executor.js.map +1 -1
- package/dist/agent/gateway-tools.md +9 -0
- package/dist/agent/image-translation-tools.d.ts +48 -0
- package/dist/agent/image-translation-tools.d.ts.map +1 -0
- package/dist/agent/image-translation-tools.js +274 -0
- package/dist/agent/image-translation-tools.js.map +1 -0
- package/dist/agent/role-manager.d.ts.map +1 -1
- package/dist/agent/role-manager.js +9 -1
- package/dist/agent/role-manager.js.map +1 -1
- package/dist/agent/tool-registry.d.ts.map +1 -1
- package/dist/agent/tool-registry.js +53 -0
- package/dist/agent/tool-registry.js.map +1 -1
- package/dist/agent/types.d.ts +22 -33
- package/dist/agent/types.d.ts.map +1 -1
- package/dist/agent/types.js.map +1 -1
- package/dist/cli/commands/start.d.ts.map +1 -1
- package/dist/cli/commands/start.js +18 -8
- package/dist/cli/commands/start.js.map +1 -1
- package/dist/cli/config/types.d.ts.map +1 -1
- package/dist/cli/config/types.js +12 -4
- package/dist/cli/config/types.js.map +1 -1
- package/dist/cli/runtime/envelope-bootstrap.d.ts.map +1 -1
- package/dist/cli/runtime/envelope-bootstrap.js +8 -1
- package/dist/cli/runtime/envelope-bootstrap.js.map +1 -1
- package/dist/connectors/drive/index.d.ts +1 -1
- package/dist/connectors/drive/index.d.ts.map +1 -1
- package/dist/connectors/drive/index.js +100 -56
- package/dist/connectors/drive/index.js.map +1 -1
- package/dist/connectors/framework/gws-utils.d.ts +3 -0
- package/dist/connectors/framework/gws-utils.d.ts.map +1 -1
- package/dist/connectors/framework/gws-utils.js +17 -0
- package/dist/connectors/framework/gws-utils.js.map +1 -1
- package/dist/envelope/enforcer.d.ts.map +1 -1
- package/dist/envelope/enforcer.js +18 -3
- package/dist/envelope/enforcer.js.map +1 -1
- package/dist/envelope/reactive-config.d.ts +1 -1
- package/dist/envelope/reactive-config.d.ts.map +1 -1
- package/dist/envelope/reactive-config.js +10 -2
- package/dist/envelope/reactive-config.js.map +1 -1
- package/dist/envelope/types.d.ts +1 -1
- package/dist/envelope/types.d.ts.map +1 -1
- package/dist/envelope/types.js.map +1 -1
- package/dist/gateways/message-router.d.ts +4 -0
- package/dist/gateways/message-router.d.ts.map +1 -1
- package/dist/gateways/message-router.js +433 -335
- package/dist/gateways/message-router.js.map +1 -1
- package/dist/gateways/session-store.d.ts +2 -0
- package/dist/gateways/session-store.d.ts.map +1 -1
- package/dist/gateways/session-store.js +41 -1
- package/dist/gateways/session-store.js.map +1 -1
- package/dist/gateways/telegram-media.d.ts +41 -0
- package/dist/gateways/telegram-media.d.ts.map +1 -0
- package/dist/gateways/telegram-media.js +242 -0
- package/dist/gateways/telegram-media.js.map +1 -0
- package/dist/gateways/telegram-message-ledger.d.ts +49 -0
- package/dist/gateways/telegram-message-ledger.d.ts.map +1 -0
- package/dist/gateways/telegram-message-ledger.js +266 -0
- package/dist/gateways/telegram-message-ledger.js.map +1 -0
- package/dist/gateways/telegram-response-presenter.d.ts +41 -0
- package/dist/gateways/telegram-response-presenter.d.ts.map +1 -0
- package/dist/gateways/telegram-response-presenter.js +250 -0
- package/dist/gateways/telegram-response-presenter.js.map +1 -0
- package/dist/gateways/telegram.d.ts +30 -7
- package/dist/gateways/telegram.d.ts.map +1 -1
- package/dist/gateways/telegram.js +444 -124
- package/dist/gateways/telegram.js.map +1 -1
- package/dist/gateways/types.d.ts +4 -2
- package/dist/gateways/types.d.ts.map +1 -1
- package/dist/operator/operator-interfaces.d.ts +1 -1
- package/dist/operator/operator-interfaces.d.ts.map +1 -1
- package/dist/operator/operator-trigger-loop.d.ts +12 -0
- package/dist/operator/operator-trigger-loop.d.ts.map +1 -1
- package/dist/operator/operator-trigger-loop.js +184 -38
- package/dist/operator/operator-trigger-loop.js.map +1 -1
- package/dist/operator/pending-report-store.d.ts +35 -0
- package/dist/operator/pending-report-store.d.ts.map +1 -0
- package/dist/operator/pending-report-store.js +155 -0
- package/dist/operator/pending-report-store.js.map +1 -0
- package/dist/operator/runtime-config.d.ts +10 -0
- package/dist/operator/runtime-config.d.ts.map +1 -0
- package/dist/operator/runtime-config.js +21 -0
- package/dist/operator/runtime-config.js.map +1 -0
- package/dist/operator/situation-report.d.ts +37 -0
- package/dist/operator/situation-report.d.ts.map +1 -1
- package/dist/operator/situation-report.js +122 -9
- package/dist/operator/situation-report.js.map +1 -1
- package/package.json +3 -1
- package/scripts/attachment/extract-office-text.js +179 -0
- package/scripts/attachment/extract-pdf-text.swift +36 -0
- package/scripts/image/fb-overlay.py +128 -0
- package/scripts/image/ocr-image.py +38 -0
- package/scripts/setup-ocr.js +120 -0
- package/templates/skills/image-translate.md +17 -22
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
const { readFileSync, statSync } = require('node:fs');
|
|
4
|
+
const { inflateRawSync } = require('node:zlib');
|
|
5
|
+
|
|
6
|
+
const MAX_ARCHIVE_BYTES = 64 * 1024 * 1024;
|
|
7
|
+
const MAX_ARCHIVE_ENTRIES = 2_000;
|
|
8
|
+
|
|
9
|
+
const [, , filePath, extension, maxOutputRaw] = process.argv;
|
|
10
|
+
const maxOutputBytes = Number(maxOutputRaw);
|
|
11
|
+
if (!filePath || !['.docx', '.xlsx'].includes(extension) || !Number.isSafeInteger(maxOutputBytes)) {
|
|
12
|
+
throw new Error('usage: extract-office-text.js <path> <.docx|.xlsx> <max-output-bytes>');
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
const prefixes =
|
|
16
|
+
extension === '.docx' ? ['word/document.xml'] : ['xl/sharedStrings.xml', 'xl/worksheets/'];
|
|
17
|
+
const entries = readOfficeEntries(filePath, prefixes);
|
|
18
|
+
const text = extension === '.docx' ? extractDocxText(entries) : extractXlsxText(entries);
|
|
19
|
+
process.stdout.write(truncateUtf8(text, maxOutputBytes));
|
|
20
|
+
|
|
21
|
+
function readOfficeEntries(path, wantedPrefixes) {
|
|
22
|
+
if (statSync(path).size > MAX_ARCHIVE_BYTES) {
|
|
23
|
+
throw new Error('Office attachment is too large');
|
|
24
|
+
}
|
|
25
|
+
const archive = readFileSync(path);
|
|
26
|
+
const endOffset = findEndOfCentralDirectory(archive);
|
|
27
|
+
const entryCount = archive.readUInt16LE(endOffset + 10);
|
|
28
|
+
const centralOffset = archive.readUInt32LE(endOffset + 16);
|
|
29
|
+
if (entryCount > MAX_ARCHIVE_ENTRIES) {
|
|
30
|
+
throw new Error('Office attachment has too many entries');
|
|
31
|
+
}
|
|
32
|
+
const result = new Map();
|
|
33
|
+
let cursor = centralOffset;
|
|
34
|
+
let extractedBytes = 0;
|
|
35
|
+
for (let index = 0; index < entryCount; index += 1) {
|
|
36
|
+
requireSignature(archive, cursor, 0x02014b50, 'central directory');
|
|
37
|
+
const flags = archive.readUInt16LE(cursor + 8);
|
|
38
|
+
const method = archive.readUInt16LE(cursor + 10);
|
|
39
|
+
const compressedSize = archive.readUInt32LE(cursor + 20);
|
|
40
|
+
const uncompressedSize = archive.readUInt32LE(cursor + 24);
|
|
41
|
+
const nameLength = archive.readUInt16LE(cursor + 28);
|
|
42
|
+
const extraLength = archive.readUInt16LE(cursor + 30);
|
|
43
|
+
const commentLength = archive.readUInt16LE(cursor + 32);
|
|
44
|
+
const localOffset = archive.readUInt32LE(cursor + 42);
|
|
45
|
+
const nameStart = cursor + 46;
|
|
46
|
+
const name = archive.subarray(nameStart, nameStart + nameLength).toString('utf8');
|
|
47
|
+
cursor = nameStart + nameLength + extraLength + commentLength;
|
|
48
|
+
if (!wantedPrefixes.some((prefix) => name === prefix || name.startsWith(prefix))) {
|
|
49
|
+
continue;
|
|
50
|
+
}
|
|
51
|
+
if ((flags & 0x1) !== 0) {
|
|
52
|
+
throw new Error('Encrypted Office attachments are not supported');
|
|
53
|
+
}
|
|
54
|
+
extractedBytes += uncompressedSize;
|
|
55
|
+
if (extractedBytes > MAX_ARCHIVE_BYTES) {
|
|
56
|
+
throw new Error('Office attachment expands too far');
|
|
57
|
+
}
|
|
58
|
+
requireSignature(archive, localOffset, 0x04034b50, 'local file header');
|
|
59
|
+
const nameSize = archive.readUInt16LE(localOffset + 26);
|
|
60
|
+
const extraSize = archive.readUInt16LE(localOffset + 28);
|
|
61
|
+
const dataStart = localOffset + 30 + nameSize + extraSize;
|
|
62
|
+
const compressed = archive.subarray(dataStart, dataStart + compressedSize);
|
|
63
|
+
const data =
|
|
64
|
+
method === 0
|
|
65
|
+
? compressed
|
|
66
|
+
: method === 8
|
|
67
|
+
? inflateRawSync(compressed, { maxOutputLength: MAX_ARCHIVE_BYTES })
|
|
68
|
+
: unsupportedCompression(method);
|
|
69
|
+
if (data.length !== uncompressedSize) {
|
|
70
|
+
throw new Error('Corrupt Office entry');
|
|
71
|
+
}
|
|
72
|
+
result.set(name, data.toString('utf8'));
|
|
73
|
+
}
|
|
74
|
+
return result;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function findEndOfCentralDirectory(archive) {
|
|
78
|
+
const minimumOffset = Math.max(0, archive.length - 65_557);
|
|
79
|
+
for (let cursor = archive.length - 22; cursor >= minimumOffset; cursor -= 1) {
|
|
80
|
+
if (archive.readUInt32LE(cursor) === 0x06054b50) {
|
|
81
|
+
return cursor;
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
throw new Error('Invalid Office attachment: ZIP directory not found');
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function requireSignature(archive, offset, expected, label) {
|
|
88
|
+
if (offset < 0 || offset + 4 > archive.length || archive.readUInt32LE(offset) !== expected) {
|
|
89
|
+
throw new Error(`Invalid Office attachment ${label}`);
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function unsupportedCompression(method) {
|
|
94
|
+
throw new Error(`Unsupported Office compression method: ${method}`);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function extractDocxText(entries) {
|
|
98
|
+
const xml = entries.get('word/document.xml');
|
|
99
|
+
if (!xml) {
|
|
100
|
+
throw new Error('Invalid DOCX attachment: word/document.xml is missing');
|
|
101
|
+
}
|
|
102
|
+
return [...xml.matchAll(/<w:p\b[^>]*>([\s\S]*?)<\/w:p>/g)]
|
|
103
|
+
.map((match) => extractXmlText(match[1], 'w:t'))
|
|
104
|
+
.filter(Boolean)
|
|
105
|
+
.join('\n');
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function extractXlsxText(entries) {
|
|
109
|
+
const sharedXml = entries.get('xl/sharedStrings.xml') || '';
|
|
110
|
+
const shared = [...sharedXml.matchAll(/<si\b[^>]*>([\s\S]*?)<\/si>/g)].map((match) =>
|
|
111
|
+
extractXmlText(match[1], 't')
|
|
112
|
+
);
|
|
113
|
+
const sheets = [...entries.entries()]
|
|
114
|
+
.filter(([name]) => /^xl\/worksheets\/sheet[^/]*\.xml$/.test(name))
|
|
115
|
+
.sort(([left], [right]) => left.localeCompare(right));
|
|
116
|
+
if (sheets.length === 0) {
|
|
117
|
+
throw new Error('Invalid XLSX attachment: no worksheets found');
|
|
118
|
+
}
|
|
119
|
+
const lines = [];
|
|
120
|
+
for (const [name, xml] of sheets) {
|
|
121
|
+
lines.push(`[${name.slice(name.lastIndexOf('/') + 1, -4)}]`);
|
|
122
|
+
for (const cell of xml.matchAll(/<c\b([^>]*)>([\s\S]*?)<\/c>/g)) {
|
|
123
|
+
const reference = readXmlAttribute(cell[1], 'r') || '?';
|
|
124
|
+
const type = readXmlAttribute(cell[1], 't');
|
|
125
|
+
const raw = firstXmlTagText(cell[2], 'v');
|
|
126
|
+
const value =
|
|
127
|
+
type === 's'
|
|
128
|
+
? shared[Number(raw)] || ''
|
|
129
|
+
: type === 'inlineStr'
|
|
130
|
+
? extractXmlText(cell[2], 't')
|
|
131
|
+
: decodeXmlEntities(raw);
|
|
132
|
+
if (value) {
|
|
133
|
+
lines.push(`${reference}=${value}`);
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
return lines.join('\n');
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
function extractXmlText(xml, tagName) {
|
|
141
|
+
const tag = tagName.replace(':', '\\:');
|
|
142
|
+
return [...xml.matchAll(new RegExp(`<${tag}\\b[^>]*>([\\s\\S]*?)<\\/${tag}>`, 'g'))]
|
|
143
|
+
.map((match) => decodeXmlEntities(match[1]))
|
|
144
|
+
.join('');
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
function firstXmlTagText(xml, tagName) {
|
|
148
|
+
const match = new RegExp(`<${tagName}\\b[^>]*>([\\s\\S]*?)<\\/${tagName}>`).exec(xml);
|
|
149
|
+
return match ? decodeXmlEntities(match[1]) : '';
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
function readXmlAttribute(attributes, name) {
|
|
153
|
+
const match = new RegExp(`(?:^|\\s)${name}=(?:"([^"]*)"|'([^']*)')`).exec(attributes);
|
|
154
|
+
return match ? match[1] || match[2] : undefined;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
function decodeXmlEntities(value) {
|
|
158
|
+
return value
|
|
159
|
+
.replace(/<[^>]+>/g, '')
|
|
160
|
+
.replace(/</g, '<')
|
|
161
|
+
.replace(/>/g, '>')
|
|
162
|
+
.replace(/"/g, '"')
|
|
163
|
+
.replace(/'/g, "'")
|
|
164
|
+
.replace(/&/g, '&')
|
|
165
|
+
.replace(/&#(\d+);/g, (_match, code) => String.fromCodePoint(Number(code)))
|
|
166
|
+
.replace(/&#x([0-9a-f]+);/gi, (_match, code) => String.fromCodePoint(parseInt(code, 16)));
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
function truncateUtf8(content, maxBytes) {
|
|
170
|
+
const bytes = Buffer.from(content);
|
|
171
|
+
if (bytes.length <= maxBytes) {
|
|
172
|
+
return content;
|
|
173
|
+
}
|
|
174
|
+
let end = maxBytes;
|
|
175
|
+
while (end > 0 && (bytes[end] & 0xc0) === 0x80) {
|
|
176
|
+
end -= 1;
|
|
177
|
+
}
|
|
178
|
+
return `${bytes.subarray(0, end).toString('utf8')}\n\n[Truncated at ${maxBytes} bytes]`;
|
|
179
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import Foundation
|
|
2
|
+
import PDFKit
|
|
3
|
+
|
|
4
|
+
guard CommandLine.arguments.count == 3, let maxBytes = Int(CommandLine.arguments[2]), maxBytes > 0 else {
|
|
5
|
+
fputs("usage: extract-pdf-text.swift <pdf-path> <max-output-bytes>\n", stderr)
|
|
6
|
+
exit(2)
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
let url = URL(fileURLWithPath: CommandLine.arguments[1])
|
|
10
|
+
guard let document = PDFDocument(url: url) else {
|
|
11
|
+
fputs("unable to open PDF\n", stderr)
|
|
12
|
+
exit(1)
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
var written = 0
|
|
16
|
+
for index in 0..<document.pageCount {
|
|
17
|
+
if let text = document.page(at: index)?.string, !text.isEmpty {
|
|
18
|
+
let separator = written == 0 ? "" : "\n\n"
|
|
19
|
+
let data = (separator + text).data(using: .utf8) ?? Data()
|
|
20
|
+
let remaining = maxBytes - written
|
|
21
|
+
if remaining <= 0 { break }
|
|
22
|
+
var bounded = Data()
|
|
23
|
+
var prefixLength = min(remaining, data.count)
|
|
24
|
+
while prefixLength > 0 {
|
|
25
|
+
let candidate = Data(data.prefix(prefixLength))
|
|
26
|
+
if String(data: candidate, encoding: .utf8) != nil {
|
|
27
|
+
bounded = candidate
|
|
28
|
+
break
|
|
29
|
+
}
|
|
30
|
+
prefixLength -= 1
|
|
31
|
+
}
|
|
32
|
+
FileHandle.standardOutput.write(bounded)
|
|
33
|
+
written += bounded.count
|
|
34
|
+
if bounded.count < data.count { break }
|
|
35
|
+
}
|
|
36
|
+
}
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Render translated text over OCR regions and return the output path as JSON."""
|
|
3
|
+
|
|
4
|
+
import json
|
|
5
|
+
import os
|
|
6
|
+
import sys
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
PADDING = 10
|
|
11
|
+
MIN_FONT_SIZE = 18
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def font_candidates():
|
|
15
|
+
configured = os.environ.get("MAMA_KOREAN_FONT")
|
|
16
|
+
return [
|
|
17
|
+
configured,
|
|
18
|
+
"/System/Library/Fonts/AppleSDGothicNeo.ttc",
|
|
19
|
+
"/usr/share/fonts/opentype/noto/NotoSansCJK-Regular.ttc",
|
|
20
|
+
"/usr/share/fonts/truetype/noto/NotoSansCJK-Regular.ttc",
|
|
21
|
+
]
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def load_font(image_draw, text, max_width, start_size):
|
|
25
|
+
from PIL import ImageFont
|
|
26
|
+
|
|
27
|
+
for candidate in font_candidates():
|
|
28
|
+
if not candidate or not Path(candidate).exists():
|
|
29
|
+
continue
|
|
30
|
+
size = max(start_size, MIN_FONT_SIZE)
|
|
31
|
+
while size >= MIN_FONT_SIZE:
|
|
32
|
+
font = ImageFont.truetype(candidate, size)
|
|
33
|
+
bounds = image_draw.textbbox((0, 0), text, font=font)
|
|
34
|
+
if bounds[2] - bounds[0] <= max_width or size == MIN_FONT_SIZE:
|
|
35
|
+
return font
|
|
36
|
+
size -= 1
|
|
37
|
+
raise RuntimeError("Korean font not found; set MAMA_KOREAN_FONT")
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def sample_text_color(array, points):
|
|
41
|
+
import numpy
|
|
42
|
+
|
|
43
|
+
x1 = max(min(point[0] for point in points), 0)
|
|
44
|
+
y1 = max(min(point[1] for point in points), 0)
|
|
45
|
+
x2 = min(max(point[0] for point in points), array.shape[1])
|
|
46
|
+
y2 = min(max(point[1] for point in points), array.shape[0])
|
|
47
|
+
region = array[y1:y2, x1:x2]
|
|
48
|
+
if region.size == 0:
|
|
49
|
+
return (20, 20, 20)
|
|
50
|
+
dark_pixels = region.reshape(-1, 3)[region.mean(axis=2).flatten() < 80]
|
|
51
|
+
if len(dark_pixels) < 3:
|
|
52
|
+
return (20, 20, 20)
|
|
53
|
+
return tuple(int(channel) for channel in dark_pixels.mean(axis=0))
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def main():
|
|
57
|
+
if len(sys.argv) < 3:
|
|
58
|
+
raise ValueError("Usage: fb-overlay.py <image> <annotations.json> [output.png]")
|
|
59
|
+
|
|
60
|
+
from PIL import Image, ImageDraw
|
|
61
|
+
import numpy
|
|
62
|
+
|
|
63
|
+
image_path = sys.argv[1]
|
|
64
|
+
annotations_path = sys.argv[2]
|
|
65
|
+
output_path = (
|
|
66
|
+
sys.argv[3]
|
|
67
|
+
if len(sys.argv) > 3
|
|
68
|
+
else str(Path(image_path).with_name(f"{Path(image_path).stem}_KR.png"))
|
|
69
|
+
)
|
|
70
|
+
with open(annotations_path, encoding="utf-8") as handle:
|
|
71
|
+
annotations = json.load(handle)
|
|
72
|
+
|
|
73
|
+
original = Image.open(image_path).convert("RGB")
|
|
74
|
+
image = original.copy()
|
|
75
|
+
pixels = numpy.array(original)
|
|
76
|
+
draw = ImageDraw.Draw(image)
|
|
77
|
+
render_items = []
|
|
78
|
+
|
|
79
|
+
for annotation in annotations:
|
|
80
|
+
points = annotation["bbox"]
|
|
81
|
+
translated = annotation.get("translated", "")
|
|
82
|
+
if not translated:
|
|
83
|
+
continue
|
|
84
|
+
x1 = min(point[0] for point in points)
|
|
85
|
+
y1 = min(point[1] for point in points)
|
|
86
|
+
x2 = max(point[0] for point in points)
|
|
87
|
+
y2 = max(point[1] for point in points)
|
|
88
|
+
font = load_font(draw, translated, x2 - x1, int((y2 - y1) * 0.92))
|
|
89
|
+
bounds = draw.textbbox((0, 0), translated, font=font)
|
|
90
|
+
width = bounds[2] - bounds[0]
|
|
91
|
+
height = bounds[3] - bounds[1]
|
|
92
|
+
render_items.append(
|
|
93
|
+
{
|
|
94
|
+
"box": (
|
|
95
|
+
x1 - PADDING,
|
|
96
|
+
y1 - PADDING,
|
|
97
|
+
max(x2, x1 + width) + PADDING,
|
|
98
|
+
max(y2, y1 + height + 4) + PADDING,
|
|
99
|
+
),
|
|
100
|
+
"text": translated,
|
|
101
|
+
"font": font,
|
|
102
|
+
"color": sample_text_color(pixels, points),
|
|
103
|
+
"height": height,
|
|
104
|
+
}
|
|
105
|
+
)
|
|
106
|
+
|
|
107
|
+
for item in render_items:
|
|
108
|
+
draw.rectangle(list(item["box"]), fill=(255, 255, 255))
|
|
109
|
+
for item in render_items:
|
|
110
|
+
x1, y1, _x2, y2 = item["box"]
|
|
111
|
+
text_y = y1 + ((y2 - y1) - item["height"]) // 2
|
|
112
|
+
draw.text(
|
|
113
|
+
(x1 + PADDING, text_y),
|
|
114
|
+
item["text"],
|
|
115
|
+
font=item["font"],
|
|
116
|
+
fill=item["color"],
|
|
117
|
+
)
|
|
118
|
+
|
|
119
|
+
image.save(output_path, quality=95)
|
|
120
|
+
print(json.dumps({"outputPath": output_path}, ensure_ascii=False))
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
if __name__ == "__main__":
|
|
124
|
+
try:
|
|
125
|
+
main()
|
|
126
|
+
except Exception as error:
|
|
127
|
+
print(json.dumps({"error": str(error)}, ensure_ascii=False), file=sys.stderr)
|
|
128
|
+
sys.exit(1)
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Extract OCR regions as JSON for MAMA image translation tools."""
|
|
3
|
+
|
|
4
|
+
import json
|
|
5
|
+
import sys
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def main():
|
|
9
|
+
if len(sys.argv) < 2:
|
|
10
|
+
raise ValueError("Usage: ocr-image.py <image_path> [--lang ja,en]")
|
|
11
|
+
|
|
12
|
+
image_path = sys.argv[1]
|
|
13
|
+
languages = ["ja", "en"]
|
|
14
|
+
for index, argument in enumerate(sys.argv[2:], 2):
|
|
15
|
+
if argument == "--lang" and index + 1 < len(sys.argv):
|
|
16
|
+
languages = sys.argv[index + 1].split(",")
|
|
17
|
+
|
|
18
|
+
import easyocr
|
|
19
|
+
|
|
20
|
+
reader = easyocr.Reader(languages, gpu=False, verbose=False)
|
|
21
|
+
output = []
|
|
22
|
+
for bbox, text, confidence in reader.readtext(image_path, detail=1):
|
|
23
|
+
output.append(
|
|
24
|
+
{
|
|
25
|
+
"bbox": [[int(point[0]), int(point[1])] for point in bbox],
|
|
26
|
+
"text": text,
|
|
27
|
+
"conf": round(confidence, 2),
|
|
28
|
+
}
|
|
29
|
+
)
|
|
30
|
+
print(json.dumps(output, ensure_ascii=False))
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
if __name__ == "__main__":
|
|
34
|
+
try:
|
|
35
|
+
main()
|
|
36
|
+
except Exception as error:
|
|
37
|
+
print(json.dumps({"error": str(error)}, ensure_ascii=False), file=sys.stderr)
|
|
38
|
+
sys.exit(1)
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
const { existsSync, mkdirSync } = require('node:fs');
|
|
4
|
+
const { homedir } = require('node:os');
|
|
5
|
+
const { dirname, join, resolve } = require('node:path');
|
|
6
|
+
const { spawnSync } = require('node:child_process');
|
|
7
|
+
|
|
8
|
+
const REQUIREMENTS = ['easyocr==1.7.2', 'Pillow>=10,<13', 'numpy>=1.26,<3'];
|
|
9
|
+
const FONT_GUIDANCE =
|
|
10
|
+
'Install a Korean/CJK font (Ubuntu/Debian: apt install fonts-noto-cjk) or set ' +
|
|
11
|
+
'MAMA_KOREAN_FONT to a readable .ttf/.ttc font file.';
|
|
12
|
+
const RUNTIME_CHECK = String.raw`
|
|
13
|
+
import os
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
import easyocr, PIL, numpy
|
|
16
|
+
from PIL import ImageFont
|
|
17
|
+
|
|
18
|
+
candidates = [
|
|
19
|
+
os.environ.get("MAMA_KOREAN_FONT"),
|
|
20
|
+
"/System/Library/Fonts/AppleSDGothicNeo.ttc",
|
|
21
|
+
"/usr/share/fonts/opentype/noto/NotoSansCJK-Regular.ttc",
|
|
22
|
+
"/usr/share/fonts/truetype/noto/NotoSansCJK-Regular.ttc",
|
|
23
|
+
]
|
|
24
|
+
for candidate in candidates:
|
|
25
|
+
if not candidate or not Path(candidate).is_file():
|
|
26
|
+
continue
|
|
27
|
+
try:
|
|
28
|
+
font = ImageFont.truetype(candidate, 18)
|
|
29
|
+
if font.getbbox("한글"):
|
|
30
|
+
print(easyocr.__version__)
|
|
31
|
+
raise SystemExit(0)
|
|
32
|
+
except OSError:
|
|
33
|
+
pass
|
|
34
|
+
raise SystemExit("Korean/CJK font is unavailable")
|
|
35
|
+
`;
|
|
36
|
+
|
|
37
|
+
function runtimeRoot() {
|
|
38
|
+
return resolve(process.env.MAMA_OCR_ENV || join(homedir(), '.mama', 'ocr-env'));
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function runtimePython(root) {
|
|
42
|
+
return process.platform === 'win32'
|
|
43
|
+
? join(root, 'Scripts', 'python.exe')
|
|
44
|
+
: join(root, 'bin', 'python3');
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function run(command, args, options = {}) {
|
|
48
|
+
const result = spawnSync(command, args, {
|
|
49
|
+
stdio: options.capture ? ['ignore', 'pipe', 'pipe'] : 'inherit',
|
|
50
|
+
encoding: 'utf8',
|
|
51
|
+
env: process.env,
|
|
52
|
+
});
|
|
53
|
+
if (result.error || result.status !== 0) {
|
|
54
|
+
const detail = result.error?.message || result.stderr?.trim() || `exit ${result.status}`;
|
|
55
|
+
throw new Error(`${command} ${args.join(' ')} failed: ${detail}`);
|
|
56
|
+
}
|
|
57
|
+
return result.stdout || '';
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function check() {
|
|
61
|
+
const python = runtimePython(runtimeRoot());
|
|
62
|
+
if (!existsSync(python)) {
|
|
63
|
+
return false;
|
|
64
|
+
}
|
|
65
|
+
try {
|
|
66
|
+
run(python, ['-c', RUNTIME_CHECK], { capture: true });
|
|
67
|
+
return true;
|
|
68
|
+
} catch {
|
|
69
|
+
return false;
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function setup() {
|
|
74
|
+
const root = runtimeRoot();
|
|
75
|
+
const bootstrapPython = process.env.MAMA_OCR_BOOTSTRAP_PYTHON || 'python3';
|
|
76
|
+
mkdirSync(dirname(root), { recursive: true, mode: 0o700 });
|
|
77
|
+
if (!existsSync(runtimePython(root))) {
|
|
78
|
+
run(bootstrapPython, ['-m', 'venv', root]);
|
|
79
|
+
}
|
|
80
|
+
const python = runtimePython(root);
|
|
81
|
+
run(python, ['-m', 'pip', 'install', '--upgrade', 'pip']);
|
|
82
|
+
run(python, ['-m', 'pip', 'install', ...REQUIREMENTS]);
|
|
83
|
+
if (!check()) {
|
|
84
|
+
throw new Error(`MAMA OCR runtime failed its dependency/font check. ${FONT_GUIDANCE}`);
|
|
85
|
+
}
|
|
86
|
+
process.stdout.write(`MAMA OCR runtime ready: ${python}\n`);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function main() {
|
|
90
|
+
const command = process.argv[2];
|
|
91
|
+
if (command === '--print-requirements') {
|
|
92
|
+
process.stdout.write(`${REQUIREMENTS.join('\n')}\n`);
|
|
93
|
+
return;
|
|
94
|
+
}
|
|
95
|
+
if (command === '--print-font-guidance') {
|
|
96
|
+
process.stdout.write(`${FONT_GUIDANCE}\n`);
|
|
97
|
+
return;
|
|
98
|
+
}
|
|
99
|
+
if (command === '--check') {
|
|
100
|
+
if (check()) {
|
|
101
|
+
process.stdout.write(`MAMA OCR runtime ready: ${runtimePython(runtimeRoot())}\n`);
|
|
102
|
+
return;
|
|
103
|
+
}
|
|
104
|
+
process.stderr.write(
|
|
105
|
+
`MAMA OCR runtime is not ready at ${runtimeRoot()}. Run: pnpm setup:ocr\n${FONT_GUIDANCE}\n`
|
|
106
|
+
);
|
|
107
|
+
process.exitCode = 1;
|
|
108
|
+
return;
|
|
109
|
+
}
|
|
110
|
+
setup();
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
try {
|
|
114
|
+
main();
|
|
115
|
+
} catch (error) {
|
|
116
|
+
process.stderr.write(
|
|
117
|
+
`MAMA OCR setup failed: ${error instanceof Error ? error.message : error}\n`
|
|
118
|
+
);
|
|
119
|
+
process.exitCode = 1;
|
|
120
|
+
}
|
|
@@ -21,38 +21,33 @@ discordScreenshot: true
|
|
|
21
21
|
|
|
22
22
|
이미지에서 텍스트를 추출하고 한국어로 번역하세요.
|
|
23
23
|
|
|
24
|
-
|
|
24
|
+
## 결과 원칙
|
|
25
25
|
|
|
26
26
|
- 메모리 컨텍스트(<relevant-memories> 태그 안의 내용)는 절대 출력하지 마세요
|
|
27
|
-
-
|
|
28
|
-
-
|
|
27
|
+
- 이미지가 대화에 직접 포함됐다면 내용을 보고 번역하세요
|
|
28
|
+
- 이미지가 파일·Drive·메시지 첨부 등 외부 위치에 있다면 현재 허용된 도구로 가져오세요
|
|
29
|
+
- 사용자가 파일 생성·업로드·전송까지 요청했다면 사용 가능한 도구를 조합해 요청한 최종 결과까지 수행하세요
|
|
30
|
+
- 쓰기·업로드·전송은 각 도구의 성공 응답을 확인하기 전에는 완료했다고 말하지 마세요
|
|
29
31
|
|
|
30
|
-
##
|
|
32
|
+
## 번역 품질
|
|
31
33
|
|
|
32
|
-
1.
|
|
33
|
-
2.
|
|
34
|
-
3.
|
|
35
|
-
4.
|
|
34
|
+
1. 모든 텍스트를 빠뜨리지 말고 번역하세요
|
|
35
|
+
2. 읽을 수 없거나 OCR이 불확실한 부분은 임의로 추정하지 말고 `[판독 불가]`로 표시하세요
|
|
36
|
+
3. 제목, 목록, 표, 말풍선 순서 등 원본 구조를 유지하세요
|
|
37
|
+
4. 단순 번역 요청이면 번역 결과만 출력하세요
|
|
38
|
+
5. 일부 텍스트를 읽거나 번역하지 못했다면 해당 항목을 부분 실패로 명확히 보고하세요
|
|
39
|
+
6. 산출물이나 전달을 요청받았다면 실제 결과와 실패 항목을 파일별로 명확히 보고하세요
|
|
36
40
|
|
|
37
41
|
## 금지 사항
|
|
38
42
|
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
❌ 요약이나 설명
|
|
43
|
-
❌ 이모지
|
|
44
|
-
❌ **도구 호출** - Read, mama_search, mama_save, Write 등 절대 사용 금지
|
|
45
|
-
❌ "이미지를 읽을 수 없습니다" - 이미지가 이미 제공되었으므로 직접 번역만 하세요
|
|
43
|
+
- 메모리 컨텍스트나 비공개 경로를 최종 답변에 노출하지 마세요
|
|
44
|
+
- 실행하지 않은 파일 생성·업로드·전송을 완료했다고 보고하지 마세요
|
|
45
|
+
- 사용할 수 있는 도구가 있는데도 직접 도구와 Code-Act 내부 도구를 혼동해 “도구가 없다”고 단정하지 마세요
|
|
46
46
|
|
|
47
47
|
## 출력 형식
|
|
48
48
|
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
**중요**:
|
|
52
|
-
|
|
53
|
-
- 이미지는 이미 컨텍스트에 포함되어 있습니다. 도구를 호출하지 말고 바로 번역을 시작하세요.
|
|
54
|
-
- "📚 메모리", "⏱️ 1턴", 도구 이름 등 메타 정보를 출력하지 마세요.
|
|
55
|
-
- 테이블, 헤더, 메타데이터 없이 순수 번역만 출력하세요.
|
|
49
|
+
- 단순 번역: 번역된 텍스트만 출력합니다
|
|
50
|
+
- 산출물 요청: 생성·저장·전달된 결과와 실제 상태만 간결히 출력합니다
|
|
56
51
|
|
|
57
52
|
## 예시
|
|
58
53
|
|