88 publishCompiledDocArtifact ,
99 storeCompiledDoc ,
1010} from '@/lib/copilot/tools/server/files/doc-compiled-store'
11+ import { PPTX_SHIM_JS } from '@/lib/copilot/tools/server/files/pptx-shim'
1112import { isDocSandboxEnabled } from '@/lib/core/config/env-flags'
1213import { CodeLanguage } from '@/lib/execution/languages'
1314import {
@@ -103,7 +104,20 @@ export async function getE2BDocFormat(fileName: string): Promise<E2BDocFormat |
103104// "file not staged" and every workspace-image embed silently fails.
104105const INPUT_PATH_RE = / \/ h o m e \/ u s e r \/ i n p u t s \/ ( [ A - Z a - z 0 - 9 _ - ] + ) / g
105106const FILE_HELPER_RE =
106- / \b (?: g e t F i l e B a s e 6 4 | a d d I m a g e | d r a w I m a g e ) \( \s * (?: [ A - Z a - z _ $ ] [ \w $ ] * \s * , \s * ) ? [ ' " ] ( [ A - Z a - z 0 - 9 _ - ] + ) [ ' " ] / g
107+ / \b (?: g e t F i l e B a s e 6 4 | a d d I m a g e | d r a w I m a g e | i n p u t _ p a t h ) \( \s * (?: [ A - Z a - z _ $ ] [ \w $ ] * \s * , \s * ) ? [ ' " ] ( [ A - Z a - z 0 - 9 _ - ] + ) [ ' " ] / g
108+
109+ /**
110+ * A .pptx or .docx source whose first line is `#!simdoc` is a template-clone
111+ * script: Python against the simdoc Deck/Doc API (opening a retained reference
112+ * staged via `input_path('wf_…')`), compiled by the Python engine instead of
113+ * the Node engine. Only meaningful when the doc sandbox is enabled — the
114+ * legacy isolated-vm path has no Python and refuses these sources explicitly.
115+ */
116+ const SIMDOC_DECK_MARKER = '#!simdoc'
117+
118+ export function isSimdocDeckSource ( source : string ) : boolean {
119+ return source . trimStart ( ) . startsWith ( SIMDOC_DECK_MARKER )
120+ }
107121
108122// The doc source is user/LLM-controlled, so bound how much it can pull into the
109123// sandbox by BYTES (per file and total) — an authenticated member must not be
@@ -372,6 +386,7 @@ function __mime(b){ if(b.length>=2&&b[0]===0x89&&b[1]===0x50)return 'image/png';
372386globalThis.getFileBase64 = async function(fileId){ const p='/home/user/inputs/'+fileId; if(!fs.existsSync(p)) throw new Error('getFileBase64: file not staged: '+fileId); const b=fs.readFileSync(p); return __mime(b)+';base64,'+b.toString('base64'); };
373387globalThis.addImage = async function(slide, fileId, opts){ if(!opts||opts.x==null||opts.y==null||opts.w==null||opts.h==null) throw new Error('addImage: opts must include x, y, w, h'); const data=await globalThis.getFileBase64(fileId); slide.addImage(Object.assign({}, opts, { data })); };
374388globalThis.iconImage = async function(IconComponent, color, size){ const React=require('react'); const RDS=require('react-dom/server'); const sharp=require('sharp'); const svg=RDS.renderToStaticMarkup(React.createElement(IconComponent,{color:color||'#000000',size:String(size||256)})); const png=await sharp(Buffer.from(svg)).png().toBuffer(); return 'image/png;base64,'+png.toString('base64'); };
389+ ${ PPTX_SHIM_JS }
375390` . trim ( )
376391
377392const DOCX_NODE_PREAMBLE = `
@@ -417,8 +432,22 @@ ${finalize}
417432})().then(() => console.log('__DOC_OK__')).catch((e) => { console.error('__DOC_ERR__' + (e && e.message ? e.message : String(e))); process.exit(1); });
418433`
419434
435+ // After a successful build, run simdoc's structural validation in the same
436+ // sandbox call. It catches the defect classes Office rejects or silently
437+ // discards (chart axis faults, stacked-label positions, broken
438+ // relationships) that a successful compile and a clean render both miss.
439+ // Best-effort by construction: on an image built before simdoc existed the
440+ // command produces no sentinel and the compile proceeds unvalidated.
441+ const command = `NODE_PATH=$(npm root -g) node /home/user/script.js
442+ __doc_status=$?
443+ if [ $__doc_status -eq 0 ] && [ -f /home/user/output.${ ext } ]; then
444+ __simdoc_report=$(python3 -m simdoc validate /home/user/output.${ ext } 2>/dev/null | tr '\\n' ' ')
445+ if [ -n "$__simdoc_report" ]; then echo "__SIMDOC_VALIDATE__$__simdoc_report"; fi
446+ fi
447+ exit $__doc_status`
448+
420449 const result = await executeShellInSandbox ( {
421- code : 'NODE_PATH=$(npm root -g) node /home/user/script.js' ,
450+ code : command ,
422451 envs : { } ,
423452 timeoutMs : DOC_COMPILE_TIMEOUT_MS ,
424453 sandboxKind : 'doc' ,
@@ -439,6 +468,7 @@ ${finalize}
439468 const out = `${ result . stdout || '' } \n${ result . error || '' } `
440469 const errMatch = out . match ( / _ _ D O C _ E R R _ _ ( [ \s \S ] * ) / )
441470 if ( out . includes ( '__DOC_OK__' ) && result . exportedFileContent ) {
471+ assertSimdocValidationPassed ( out , ext )
442472 return Buffer . from ( result . exportedFileContent , 'base64' )
443473 }
444474 if ( errMatch ) {
@@ -456,14 +486,64 @@ ${finalize}
456486 )
457487}
458488
489+ interface SimdocIssue {
490+ code ?: string
491+ part ?: string
492+ message ?: string
493+ fix ?: string
494+ }
495+
496+ const MAX_REPORTED_VALIDATION_ISSUES = 10
497+
498+ /**
499+ * Parses the __SIMDOC_VALIDATE__ sentinel a pptx compile emits and throws a
500+ * DocCompileUserError when the built deck failed structural validation. A
501+ * missing or unparseable sentinel means the toolkit is absent or misbehaved —
502+ * that degrades to an unvalidated compile, never a failed one.
503+ */
504+ function assertSimdocValidationPassed ( compileOutput : string , ext : string ) : void {
505+ const sentinel = compileOutput . match ( / _ _ S I M D O C _ V A L I D A T E _ _ ( .* ) / )
506+ if ( ! sentinel ?. [ 1 ] ) return
507+ let report : { ok ?: boolean ; issues ?: SimdocIssue [ ] }
508+ try {
509+ report = JSON . parse ( sentinel [ 1 ] . trim ( ) )
510+ } catch {
511+ logger . warn ( 'simdoc validation output was not parseable; compile proceeds unvalidated' )
512+ return
513+ }
514+ if ( report . ok !== false || ! Array . isArray ( report . issues ) || report . issues . length === 0 ) return
515+ const lines = report . issues . slice ( 0 , MAX_REPORTED_VALIDATION_ISSUES ) . map ( ( issue ) => {
516+ const location = issue . part ? ` ${ issue . part } :` : ''
517+ const fix = issue . fix ? ` Fix: ${ issue . fix } .` : ''
518+ return `- [${ issue . code ?? 'issue' } ]${ location } ${ issue . message ?? 'unknown' } .${ fix } `
519+ } )
520+ const extra =
521+ report . issues . length > lines . length ? `\n(+${ report . issues . length - lines . length } more)` : ''
522+ const app = ext === 'docx' ? 'Word' : 'PowerPoint'
523+ throw new DocCompileUserError (
524+ `${ ext . toUpperCase ( ) } structural validation failed — ${ app } would reject or silently discard content in this file. Fix the source and retry:\n${ lines . join ( '\n' ) } ${ extra } `
525+ )
526+ }
527+
459528async function buildCompiledDoc (
460529 args : CompileArgs ,
461530 fmt : E2BDocFormat ,
462531 referencedImages : ReferencedImageResolution
463532) : Promise < CompiledDocResult > {
464533 const { source, fileName, workspaceId, filePrincipal } = args
465- const buffer =
466- fmt . engine === 'node'
534+ const cloneDeck = ( fmt . ext === 'pptx' || fmt . ext === 'docx' ) && isSimdocDeckSource ( source )
535+ const buffer = cloneDeck
536+ ? await compileDocViaE2BPython (
537+ {
538+ source : wrapSimdocDeckSource ( source , fmt . ext as 'pptx' | 'docx' ) ,
539+ fileName,
540+ workspaceId,
541+ filePrincipal,
542+ } ,
543+ fmt ,
544+ referencedImages
545+ )
546+ : fmt . engine === 'node'
467547 ? await compileDocViaE2BNode (
468548 { source, fileName, workspaceId, filePrincipal } ,
469549 fmt . ext as 'pptx' | 'docx' ,
@@ -490,6 +570,57 @@ async function buildCompiledDoc(
490570 }
491571}
492572
573+ // Template-clone scripts author against the simdoc Deck (pptx) / Doc (docx)
574+ // API. The prelude supplies input_path (staged workspace files) and
575+ // OUTPUT_PATH; the finalizer saves the expected variable (scripts never save
576+ // themselves, mirroring the JS engines' finalizers) and then structurally
577+ // validates the result in-process. The validation import degrades on images
578+ // built before simdoc existed.
579+ function simdocPrelude ( ext : 'pptx' | 'docx' ) : string {
580+ return `
581+ import os as __sim_os
582+ OUTPUT_PATH = '/home/user/output.${ ext } '
583+ def input_path(file_id):
584+ __p = '/home/user/inputs/' + file_id
585+ if not __sim_os.path.exists(__p):
586+ raise FileNotFoundError(
587+ 'input_path: file not staged: ' + file_id +
588+ ' (pass the workspace file id as a string literal at the call site)'
589+ )
590+ return __p
591+ ` . trim ( )
592+ }
593+
594+ function simdocFinalize ( ext : 'pptx' | 'docx' ) : string {
595+ const variable = ext === 'pptx' ? 'deck' : 'doc'
596+ const className = ext === 'pptx' ? 'Deck' : 'Doc'
597+ return `
598+ try:
599+ ${ variable }
600+ except NameError as __sim_err:
601+ raise RuntimeError(
602+ "simdoc ${ ext } scripts must create a ${ className } named '${ variable } ': ${ variable } = ${ className } .open(input_path('wf_...'))"
603+ ) from __sim_err
604+ ${ variable } .save(OUTPUT_PATH)
605+ try:
606+ from simdoc.validate import validate_file as __sim_validate
607+ except ImportError:
608+ __sim_validate = None
609+ if __sim_validate is not None:
610+ __sim_report = __sim_validate(OUTPUT_PATH)
611+ if not __sim_report.ok:
612+ import json as __sim_json
613+ raise RuntimeError(
614+ 'structural validation failed: '
615+ + __sim_json.dumps([__i.to_dict() for __i in __sim_report.issues[:10]])
616+ )
617+ ` . trim ( )
618+ }
619+
620+ function wrapSimdocDeckSource ( source : string , ext : 'pptx' | 'docx' ) : string {
621+ return `${ simdocPrelude ( ext ) } \n${ source } \n${ simdocFinalize ( ext ) } `
622+ }
623+
493624interface CompilableFormat {
494625 magic : Buffer
495626 taskId : SandboxTaskId
@@ -513,6 +644,11 @@ async function compileDocInLegacySandbox(
513644 if ( ! format ) {
514645 throw new DocCompileUserError ( 'Document is still being generated' , { pending : true } )
515646 }
647+ if ( ( fmt . ext === 'pptx' || fmt . ext === 'docx' ) && isSimdocDeckSource ( args . source ) ) {
648+ throw new DocCompileUserError (
649+ 'Template-clone scripts (#!simdoc) require the document sandbox, which is not enabled. Build the document with the injected JavaScript library instead.'
650+ )
651+ }
516652
517653 const cacheKey = sha256Hex ( `.${ fmt . ext } ${ args . source } ${ args . workspaceId } ` )
518654 const cached = compiledDocCache . get ( cacheKey )
0 commit comments