$ cat node-template.py
PDF Translator
// Translates one Drive document into another language keeping its page layout (columns, tables, figures) and returns a translated PDF, a bilingual PDF for review (each original page beside its translation, on by default), and a report. Word, PowerPoint and text uploads are translated from the PDF Drive made of them, so they also come back as a PDF, never as an editable file. The run succeeds only when every passage is translated and the result passes the checks for leftover untranslated text and changed numbers; otherwise it fails with a clear message naming the problem and the pages, and returns no PDF. An optional Style says in your own words how the translation should read (formal, plain language, technical). Scanned PDFs (no text layer) are refused. Text inside pictures is not translated. For a text-only reading copy, use the Drive Translate menu instead. To keep the result in Drive, connect it to Save File.
1"""pdf-translator — translate one Drive document into another language, keeping its layout.23BabelDOC does the translation, behind one gais call. This node only checks4its inputs, fetches the files from Drive, and names the results:56 1. Validate the inputs (Drive ids, languages, pages) before touching Drive.7 2. ``Gais.drive.download(item_id, as_pdf=True)``: the item's PDF. A PDF upload8 comes as it is; a Word, PowerPoint or text upload as the PDF Drive made of9 it, so every input comes back as a translated PDF (decision C1). A10 spreadsheet, image, audio or video item, or one still processing, answers11 422 and the route's own message is shown.12 3. The download must be a PDF (``%PDF-`` in its first KiB). An older13 www-emblema ignores ``as=pdf`` and returns the original file, which would14 otherwise reach BabelDOC as a confusing failure.15 4. The glossary, when given: ``Gais.drive.download(item_id)`` (the original16 bytes), CSV or XLSX, copied to a scratch file with the right extension.17 5. ``Gais.pdf_translate.translate``: BabelDOC behind the billing shim, the18 every-passage ledger and the leftover-text and number checks all live in19 that gais adapter. It raises ``PdfTranslateError`` (a failure class and a20 content-free message), or returns the translated PDF, the bilingual PDF21 when asked, and the report.22 6. Copy the PDFs to /data/output as ``<name>.<target>.pdf`` and23 ``<name>.<target>.bilingual.pdf``; add the glossary line to the report.2425No document text in errors or logs (decision C5). The worker logs a node's26whole stderr, which ships to Loki. So:27 * a ``PdfTranslateError`` prints ``{"error", "errorType"}`` and no traceback;28 * a Drive failure prints the www route's own message, never a raw body;29 * input errors describe the expected value and never echo the given one (an30 edge can carry any upstream text);31 * any other exception prints its type and the stack frames, never its32 message, since a message can quote what it failed on;33 * nothing is printed on success.3435The node itself imports no HTTP client and no os/pathlib/shutil (lint): the36token-bearing Drive calls are inside gais.37"""3839from __future__ import annotations4041import hashlib42import json43import re44import sys45import tempfile46import traceback47from typing import Any4849from gais import BackendTimeout, Gais, GaisError, InvalidParams, RemoteError5051OUTPUT_DIR = "/data/output"52#: Parent of the run's scratch directories (None = tempfile's default).53SCRATCH_DIR: str | None = None5455#: The languages the adapter translates between: the platform's own list and56#: order (www-emblema `AVAILABLE_LANGUAGES`, the settings page's language57#: select), with English names.58LANGUAGES: dict[str, str] = {59 "it": "Italian",60 "en": "English",61 "fr": "French",62 "de": "German",63 "es": "Spanish",64 "ja": "Japanese",65 "pt": "Portuguese",66 "zh": "Chinese",67 "ko": "Korean",68 "ru": "Russian",69 "nl": "Dutch",70}71#: The names the settings page shows, accepted from an edge as well.72NATIVE_NAMES: dict[str, str] = {73 "it": "Italiano", "en": "English", "fr": "Français", "de": "Deutsch", "es": "Español",74 "ja": "日本語", "pt": "Português", "zh": "中文", "ko": "한국어", "ru": "Русский",75 "nl": "Nederlands",76}77DEFAULT_TARGET = "it"78#: The adapter reads the source language from the document's text.79AUTO = "auto"80DEFAULT_SOURCE = AUTO81DEFAULT_NAME = "document"8283_UUID_RE = re.compile(84 r"[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}",85 re.IGNORECASE,86)87_FILE_MENTION = "mention://drive_file/"88_FOLDER_MENTION = "mention://drive_folder/"8990# "it", "IT", "it-IT", "pt_BR"; full English names are accepted too.91_LANGUAGE_RE = re.compile(r"([a-z]{2})(?:[-_][a-z0-9]{2,8})?")92_LANGUAGE_BY_NAME = {93 name.lower(): code94 for names in (LANGUAGES, NATIVE_NAMES)95 for code, name in names.items()96}9798# "1-5,8": page numbers and closed ranges, after whitespace is removed.99_PAGES_RE = re.compile(r"\d+(?:-\d+)?(?:,\d+(?:-\d+)?)*")100101# Readers accept the header anywhere in the first KiB.102_PDF_MAGIC = b"%PDF-"103_PDF_HEADER_WINDOW = 1024104105# Output names follow the PDF Builder's rules: path and Windows-reserved106# characters, `%` (keys are URL-decoded before their traversal check), `#` and107# `?`, controls. The byte cap keeps `<stem>.<lang>.bilingual.pdf` under the108# 255-byte file-name limit for names in non-Latin scripts.109_UNSAFE_NAME_CHARS = re.compile(r'[\x00-\x1f\x7f<>:"/\\|?*%#]')110_CONTROL_CHARS = re.compile(r"[\x00-\x1f\x7f]")111_MAX_NAME_CHARS = 120112_MAX_NAME_BYTES = 200113# The source file's own extension, dropped from the output name.114_SOURCE_EXTENSION = re.compile(115 r"\.(?:pdf|docx?|pptx?|odt|odp|rtf|txt|md|markdown|html?)$", re.IGNORECASE116)117118_GLOSSARY_KINDS = ("csv", "xlsx")119_GLOSSARY_MIME_KINDS = {120 "text/csv": "csv",121 "application/csv": "csv",122 "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet": "xlsx",123}124125_ROUTE_CODE_RE = re.compile(r"[A-Z][A-Z0-9_]{1,63}")126_MAX_MESSAGE_CHARS = 300127_COPY_CHUNK = 1024 * 1024128129_REBUILD_EXECUTOR = (130 "The PDF Translator is not installed in this server's Python executor image: "131 "an operator must rebuild emblema/workspace-executor-python"132)133_NOT_A_PDF = (134 "Drive did not return a PDF for this document. This server's www-emblema may "135 "predate the PDF Translator (an operator must update it), or the stored PDF is damaged"136)137_NO_PDF = (138 "This Drive file has no PDF to translate, or it is still being processed. "139 "Spreadsheets, images, audio and video cannot be translated here; "140 "a new upload can be translated once its processing ends"141)142_PAGES_FORMAT = (143 "Pages must be page numbers and ranges separated by commas, counting from 1, "144 "e.g. 1-5,8"145)146147148class NodeError(Exception):149 """A failure whose message is safe to show and to log.150151 It says what went wrong and what to do, never document text (C5).152 """153154 def __init__(self, message: str, error_type: str) -> None:155 super().__init__(message)156 self.error_type = error_type157158159# ── inputs ──────────────────────────────────────────────────────────────────160161162def _drive_item_id(value: Any, label: str) -> str | None:163 """A bare UUID or ``mention://drive_file/<uuid>``; None when not given.164165 A one-element list is accepted (the picker may store one).166 """167 if isinstance(value, list):168 items = [v for v in value if v not in (None, "")]169 if len(items) > 1:170 raise NodeError(171 f"{label} takes one Drive file, got {len(items)}; "172 "use one PDF Translator per file",173 "invalid_input",174 )175 value = items[0] if items else None176 if value is None:177 return None178 if not isinstance(value, str):179 raise NodeError(180 f"{label} must be a Drive file id, got a {type(value).__name__}", "invalid_input"181 )182 text = value.strip()183 if not text:184 return None185 if text.startswith(_FOLDER_MENTION):186 raise NodeError(f"{label} is a Drive folder; pick a file", "invalid_input")187 if text.startswith(_FILE_MENTION):188 text = text[len(_FILE_MENTION):]189 if not _UUID_RE.fullmatch(text):190 raise NodeError(191 f"{label} is not a Drive file id (expected a UUID or mention://drive_file/<uuid>)",192 "invalid_input",193 )194 return text195196197def _language(value: Any, label: str, default: str, *, allow_auto: bool = False) -> str:198 """A supported language code (or "auto" where allowed); the default when not given."""199 if value is None or (isinstance(value, str) and not value.strip()):200 return default201 key = value.strip().lower() if isinstance(value, str) else ""202 if allow_auto and key in (AUTO, "auto-detect", "detect"):203 return AUTO204 match = _LANGUAGE_RE.fullmatch(key)205 code = _LANGUAGE_BY_NAME.get(key) or (match.group(1) if match else None)206 if code not in LANGUAGES:207 raise NodeError(208 f"{label} must be one of {', '.join(LANGUAGES)}", "invalid_input"209 )210 return code211212213def _pages(value: Any) -> str | None:214 """The page selection without whitespace ("1-5,8"); None for every page."""215 if value is None:216 return None217 if isinstance(value, int) and not isinstance(value, bool):218 value = str(value)219 if not isinstance(value, str):220 raise NodeError(_PAGES_FORMAT, "invalid_input")221 spec = re.sub(r"\s+", "", value)222 if not spec:223 return None224 if not _PAGES_RE.fullmatch(spec):225 raise NodeError(_PAGES_FORMAT, "invalid_input")226 for part in spec.split(","):227 first, _, last = part.partition("-")228 start = int(first)229 end = int(last) if last else start230 if start < 1 or end < start:231 raise NodeError(_PAGES_FORMAT, "invalid_input")232 return spec233234235def _flag(value: Any, *, default: bool = False) -> bool:236 """A boolean input; missing or empty (an edge with no value) is ``default``."""237 if value is None or (isinstance(value, str) and not value.strip()):238 return default239 if isinstance(value, str):240 return value.strip().lower() in ("true", "1", "yes", "on")241 if isinstance(value, (bool, int)):242 return bool(value)243 return False244245246def _style(value: Any) -> str | None:247 """The free-text style, or None when empty; the adapter caps its length."""248 if value is None:249 return None250 if not isinstance(value, str):251 raise NodeError("Style must be text", "invalid_input")252 return value.strip() or None253254255def _model_id(value: Any) -> str | None:256 """The llm-picker selection; the planner fills an empty one with the default."""257 return value.strip() if isinstance(value, str) and value.strip() else None258259260# ── Drive ───────────────────────────────────────────────────────────────────261262263def _clean(text: Any) -> str:264 """One line, clipped; empty when it carries a URL (a presigned one is a credential)."""265 if not isinstance(text, str):266 return ""267 line = " ".join(text.split())268 return "" if "://" in line else line[:_MAX_MESSAGE_CHARS]269270271def _route_failure(exc: RemoteError) -> tuple[str, str | None]:272 """The www route's own ``{message, errorCode}`` when the body is that JSON.273274 Route messages are written by www-emblema and hold no document text. Any275 other body (a storage error page) is not shown.276 """277 message, code = "", None278 try:279 body = json.loads(exc.stderr) if isinstance(exc.stderr, str) else None280 except ValueError:281 body = None282 if isinstance(body, dict):283 message = _clean(body.get("message"))284 raw_code = body.get("errorCode")285 if isinstance(raw_code, str) and _ROUTE_CODE_RE.fullmatch(raw_code):286 code = raw_code287 if not message and exc.args:288 # The SDK may carry the route's message as the exception's own; its289 # generic "GET <url> failed" is dropped by _clean (it holds a URL).290 message = _clean(exc.args[0])291 return message, code292293294def _drive_failure(exc: GaisError, label: str) -> NodeError:295 prefix = f"{label}: " if label else ""296 if isinstance(exc, RemoteError):297 status = exc.http_status298 message, code = _route_failure(exc)299 if status == 422:300 return NodeError(prefix + (message or _NO_PDF), code or "NO_PDF")301 if status in (401, 403):302 return NodeError(303 prefix + (message or "You are not allowed to download this Drive file"),304 code or "DOWNLOAD_FORBIDDEN",305 )306 if status == 404:307 return NodeError(prefix + (message or "Drive file not found"), code or "ITEM_NOT_FOUND")308 detail = f": {message}" if message else ""309 return NodeError(310 f"{prefix}Drive could not provide the file (HTTP {status}){detail}",311 code or "drive_unavailable",312 )313 if isinstance(exc, BackendTimeout):314 return NodeError(315 f"{prefix}Downloading the file from Drive timed out; try again", "drive_timeout"316 )317 if isinstance(exc, InvalidParams):318 return NodeError(f"{prefix}Not a Drive file id", "invalid_input")319 detail = _clean(str(exc)) or type(exc).__name__320 return NodeError(321 f"{prefix}Could not download the file from Drive ({detail})", "drive_unavailable"322 )323324325def _staged_file(result: Any, label: str) -> str:326 path = (getattr(result, "files", None) or {}).get("file")327 if not path:328 prefix = f"{label}: " if label else ""329 raise NodeError(f"{prefix}Drive returned no file", "drive_unavailable")330 return str(path)331332333def _download_pdf(item_id: str) -> tuple[str, dict[str, Any]]:334 """The item's PDF, staged by gais, and the download metadata."""335 try:336 result = Gais.drive.download(item_id, as_pdf=True)337 except TypeError as exc:338 # An executor image older than the PDF Translator: no as_pdf in its SDK.339 if "as_pdf" in str(exc):340 raise NodeError(_REBUILD_EXECUTOR, "babeldoc_missing") from None341 raise342 except GaisError as exc:343 raise _drive_failure(exc, "") from None344 return _staged_file(result, ""), dict(getattr(result, "metadata", None) or {})345346347def _require_pdf(path: str) -> None:348 with open(path, "rb") as fh:349 head = fh.read(_PDF_HEADER_WINDOW)350 if _PDF_MAGIC not in head:351 raise NodeError(_NOT_A_PDF, "not_a_pdf")352353354def _display_name(value: Any) -> str:355 """A Drive file name for the report: one line, clipped."""356 text = _CONTROL_CHARS.sub(" ", value) if isinstance(value, str) else ""357 return " ".join(text.split())[:_MAX_NAME_CHARS]358359360def _glossary_kind(name: str, mime_type: str) -> str:361 extension = name.rsplit(".", 1)[1].lower() if "." in name else ""362 if extension in _GLOSSARY_KINDS:363 return extension364 kind = _GLOSSARY_MIME_KINDS.get(mime_type.split(";", 1)[0].strip().lower())365 if kind:366 return kind367 shown = f" (this one is .{extension})" if re.fullmatch(r"[a-z0-9]{1,10}", extension) else ""368 raise NodeError(f"Glossary file must be a CSV or XLSX file{shown}", "glossary_invalid")369370371def _short_hash(value: str) -> str:372 """The first 12 characters of a content hash, without an ``algo:`` prefix."""373 return value.rsplit(":", 1)[-1].strip()[:12]374375376def _glossary(item_id: str, scratch_dir: str) -> tuple[str, str]:377 """Fetch the glossary; return its path for the adapter and the report line."""378 try:379 result = Gais.drive.download(item_id)380 except GaisError as exc:381 raise _drive_failure(exc, "Glossary file") from None382 source = _staged_file(result, "Glossary file")383 metadata = dict(getattr(result, "metadata", None) or {})384 name = _display_name(metadata.get("name"))385 kind = _glossary_kind(name, str(metadata.get("mimeType") or ""))386 path = f"{scratch_dir}/glossary.{kind}"387 digest = _copy_file(source, path)388 drive_hash = str(metadata.get("hash") or "").strip()389 fingerprint = f"hash {_short_hash(drive_hash)}" if drive_hash else f"sha256 {digest[:12]}"390 return path, f"Glossary: {name or 'unnamed file'} ({fingerprint})"391392393# ── translation ─────────────────────────────────────────────────────────────394395396def _translate(pdf_path: str, **kwargs: Any) -> Any:397 """``Gais.pdf_translate.translate``, with its failures made node errors."""398 try:399 adapter = Gais.pdf_translate400 except (AttributeError, ImportError):401 # The executor image predates the adapter (or lost one of its modules).402 raise NodeError(_REBUILD_EXECUTOR, "babeldoc_missing") from None403 try:404 return adapter.translate(pdf_path, **kwargs)405 except adapter.PdfTranslateError as exc:406 # Content-free by the adapter's contract. One line: the worker shows407 # only the last line of a multi-line error.408 message = "; ".join(line.strip() for line in str(exc).splitlines() if line.strip())409 failure_class = getattr(exc, "failure_class", None) or "pdf_translate_failed"410 raise NodeError(message or "The translation failed", str(failure_class)) from None411 except InvalidParams as exc:412 # An argument the PDF itself refutes ("pages selects no page of this413 # 3-page PDF"). The adapter's argument messages name no document text.414 raise NodeError(_clean(str(exc)) or "The translation's inputs were refused", "invalid_input") from None415416417def _result_file(result: Any, key: str, label: str) -> str:418 path = (getattr(result, "files", None) or {}).get(key)419 if not path:420 raise NodeError(f"BabelDOC finished without the {label}", "babeldoc_crash")421 return str(path)422423424# ── output ──────────────────────────────────────────────────────────────────425426427def _output_stem(name: Any) -> str:428 """The source file's name, made a safe file-name stem."""429 base = str(name or "").replace("\\", "/").rsplit("/", 1)[-1].strip()430 stem = _UNSAFE_NAME_CHARS.sub(" ", _SOURCE_EXTENSION.sub("", base))431 stem = re.sub(r"\s+", " ", stem).lstrip(". ").rstrip()[:_MAX_NAME_CHARS]432 while len(stem.encode("utf-8")) > _MAX_NAME_BYTES:433 stem = stem[:-1]434 return stem.rstrip(" .") or DEFAULT_NAME435436437def _copy_file(src: str, dst: str) -> str:438 """Stream-copy ``src`` to ``dst``; return the content's sha256."""439 digest = hashlib.sha256()440 with open(src, "rb") as fin, open(dst, "wb") as fout:441 while chunk := fin.read(_COPY_CHUNK):442 digest.update(chunk)443 fout.write(chunk)444 return digest.hexdigest()445446447# ── run ─────────────────────────────────────────────────────────────────────448449450def process(inputs: dict[str, Any]) -> dict[str, Any]:451 item_id = _drive_item_id(inputs.get("driveItemId"), "Document")452 if item_id is None:453 raise NodeError(454 "Required input 'driveItemId' not provided: pick the Drive file to translate",455 "invalid_input",456 )457 target = _language(inputs.get("targetLanguage"), "Target Language", DEFAULT_TARGET)458 source = _language(inputs.get("sourceLanguage"), "Source Language", DEFAULT_SOURCE, allow_auto=True)459 if source == target:460 raise NodeError(461 f"Source and target language are both {LANGUAGES[target]}; "462 "pick a different target language",463 "invalid_input",464 )465 pages = _pages(inputs.get("pages"))466 # On unless switched off: the original page beside its translation lets a467 # reviewer check every line, and keeps stamps and signatures as they are.468 bilingual = _flag(inputs.get("bilingual"), default=True)469 style = _style(inputs.get("style"))470 model_id = _model_id(inputs.get("llmModelId"))471 glossary_id = _drive_item_id(inputs.get("glossary"), "Glossary")472473 pdf_path, pdf_metadata = _download_pdf(item_id)474 _require_pdf(pdf_path)475476 glossary_path, glossary_line = None, ""477 if glossary_id:478 glossary_dir = tempfile.mkdtemp(prefix="pdf-translator-glossary-", dir=SCRATCH_DIR)479 glossary_path, glossary_line = _glossary(glossary_id, glossary_dir)480481 result = _translate(482 pdf_path,483 target_lang=target,484 source_lang=source,485 model_id=model_id,486 pages=pages,487 bilingual=bilingual,488 glossary_path=glossary_path,489 out_dir=tempfile.mkdtemp(prefix="pdf-translator-out-", dir=SCRATCH_DIR),490 style=style,491 )492 translated_src = _result_file(result, "translated", "translated PDF")493 bilingual_src = _result_file(result, "bilingual", "bilingual PDF") if bilingual else None494495 stem = _output_stem(pdf_metadata.get("name"))496 translated_name = f"{stem}.{target}.pdf"497 _copy_file(translated_src, f"{OUTPUT_DIR}/{translated_name}")498 bilingual_name = None499 if bilingual_src:500 bilingual_name = f"{stem}.{target}.bilingual.pdf"501 _copy_file(bilingual_src, f"{OUTPUT_DIR}/{bilingual_name}")502503 report = (getattr(result, "text", None) or "").strip() or (504 f"Translated from {LANGUAGES.get(source, 'the detected language')} to {LANGUAGES[target]}."505 )506 if glossary_line:507 report = f"{report}\n{glossary_line}"508 return {509 "translatedPdf": translated_name,510 "bilingualPdf": bilingual_name,511 "report": report,512 }513514515def _fail(payload: dict[str, Any]) -> None:516 print(json.dumps(payload), file=sys.stderr)517 sys.exit(1)518519520def main() -> None:521 try:522 envelope = json.loads(sys.stdin.read() or "{}")523 if not isinstance(envelope, dict):524 envelope = {}525 inputs = envelope.get("inputs") or {}526 output = process(inputs if isinstance(inputs, dict) else {})527 except NodeError as exc:528 _fail({"error": str(exc), "errorType": exc.error_type})529 except Exception as exc: # noqa: BLE001 — every other failure, message withheld (C5)530 _fail({531 "error": (532 f"Unexpected failure in the PDF Translator ({type(exc).__name__}). "533 "Its details are withheld so no document text reaches the logs"534 ),535 "errorType": type(exc).__name__,536 "traceback": "".join(traceback.format_tb(exc.__traceback__)),537 })538 else:539 json.dump(output, sys.stdout)540541542if __name__ == "__main__":543 main()544$ git log --oneline
First release: translates a Drive document keeping its layout, with BabelDOC; returns the translated PDF and a bilingual PDF, or fails with a clear reason.