@@ -330,6 +330,169 @@ test.describe('embed regression (real editor)', () => {
330330 expect ( result . size ) . toBeGreaterThan ( 500 ) ;
331331 } ) ;
332332
333+ test ( 'saves a docx after inserting an image by URL, with the image bytes in the output' , async ( { page } ) => {
334+ // Guards the serverless image pipeline (prepareEditorIframe patch 4).
335+ // Without it the SDK serializes the raw external URL into the DOCY and
336+ // x2t.wasm blocks the main thread forever (the save never returns and
337+ // the whole tab freezes) -- see
338+ // docs/explorations/2026-08-15-image-save-hang-and-verification-gap.md.
339+ const result = await page . evaluate ( async ( ) => {
340+ const crcTable = ( ( ) => {
341+ const t = new Uint32Array ( 256 ) ;
342+ for ( let n = 0 ; n < 256 ; n ++ ) {
343+ let c = n ;
344+ for ( let k = 0 ; k < 8 ; k ++ ) c = c & 1 ? 0xedb88320 ^ ( c >>> 1 ) : c >>> 1 ;
345+ t [ n ] = c >>> 0 ;
346+ }
347+ return t ;
348+ } ) ( ) ;
349+ const crc32 = ( b : Uint8Array ) => {
350+ let c = 0xffffffff ;
351+ for ( let i = 0 ; i < b . length ; i ++ ) c = crcTable [ ( c ^ b [ i ] ) & 0xff ] ^ ( c >>> 8 ) ;
352+ return ( c ^ 0xffffffff ) >>> 0 ;
353+ } ;
354+ const enc = new TextEncoder ( ) ;
355+ const makeZip = ( entries : Array < { name : string ; text : string } > ) => {
356+ const ch : number [ ] = [ ] ;
357+ const cen : number [ ] = [ ] ;
358+ const u16 = ( a : number [ ] , v : number ) => a . push ( v & 0xff , ( v >> 8 ) & 0xff ) ;
359+ const u32 = ( a : number [ ] , v : number ) => a . push ( v & 0xff , ( v >> 8 ) & 0xff , ( v >> 16 ) & 0xff , ( v >> 24 ) & 0xff ) ;
360+ for ( const { name, text } of entries ) {
361+ const nb = enc . encode ( name ) ;
362+ const d = enc . encode ( text ) ;
363+ const crc = crc32 ( d ) ;
364+ const off = ch . length ;
365+ u32 ( ch , 0x04034b50 ) ;
366+ u16 ( ch , 20 ) ;
367+ u16 ( ch , 0 ) ;
368+ u16 ( ch , 0 ) ;
369+ u32 ( ch , 0 ) ;
370+ u32 ( ch , crc ) ;
371+ u32 ( ch , d . length ) ;
372+ u32 ( ch , d . length ) ;
373+ u16 ( ch , nb . length ) ;
374+ u16 ( ch , 0 ) ;
375+ ch . push ( ...nb , ...d ) ;
376+ u32 ( cen , 0x02014b50 ) ;
377+ u16 ( cen , 20 ) ;
378+ u16 ( cen , 20 ) ;
379+ u16 ( cen , 0 ) ;
380+ u16 ( cen , 0 ) ;
381+ u32 ( cen , 0 ) ;
382+ u32 ( cen , crc ) ;
383+ u32 ( cen , d . length ) ;
384+ u32 ( cen , d . length ) ;
385+ u16 ( cen , nb . length ) ;
386+ u16 ( cen , 0 ) ;
387+ u16 ( cen , 0 ) ;
388+ u16 ( cen , 0 ) ;
389+ u16 ( cen , 0 ) ;
390+ u32 ( cen , 0 ) ;
391+ u32 ( cen , off ) ;
392+ cen . push ( ...nb ) ;
393+ }
394+ const co = ch . length ;
395+ ch . push ( ...cen ) ;
396+ u32 ( ch , 0x06054b50 ) ;
397+ u16 ( ch , 0 ) ;
398+ u16 ( ch , 0 ) ;
399+ u16 ( ch , entries . length ) ;
400+ u16 ( ch , entries . length ) ;
401+ u32 ( ch , cen . length ) ;
402+ u32 ( ch , co ) ;
403+ u16 ( ch , 0 ) ;
404+ return new Uint8Array ( ch ) ;
405+ } ;
406+ const docx = makeZip ( [
407+ {
408+ name : '[Content_Types].xml' ,
409+ text :
410+ '<?xml version="1.0" encoding="UTF-8" standalone="yes"?>' +
411+ '<Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types">' +
412+ '<Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml"/>' +
413+ '<Default Extension="xml" ContentType="application/xml"/>' +
414+ '<Override PartName="/word/document.xml" ContentType="application/vnd.openxmlformats-officedocument.wordprocessingml.document.main+xml"/>' +
415+ '</Types>' ,
416+ } ,
417+ {
418+ name : '_rels/.rels' ,
419+ text :
420+ '<?xml version="1.0" encoding="UTF-8" standalone="yes"?>' +
421+ '<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">' +
422+ '<Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument" Target="word/document.xml"/>' +
423+ '</Relationships>' ,
424+ } ,
425+ {
426+ name : 'word/document.xml' ,
427+ text :
428+ '<?xml version="1.0" encoding="UTF-8" standalone="yes"?>' +
429+ '<w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main">' +
430+ '<w:body><w:p><w:r><w:t>image save</w:t></w:r></w:p></w:body></w:document>' ,
431+ } ,
432+ ] ) ;
433+ await post ( 'document:open-buffer' , { fileName : 'imgsave.docx' , buffer : docx . buffer , readonly : false } ) ;
434+
435+ // Locate the editor API and insert an image by URL (the flow that
436+ // used to freeze the tab on save).
437+ const findApi = ( ) : any => {
438+ const visit = ( win : Window ) : any => {
439+ try {
440+ const scope = win as any ;
441+ const api = scope . Asc ?. editor || scope . editor ;
442+ if ( api && typeof api . AddImageUrlAction === 'function' ) return api ;
443+ } catch {
444+ /* cross-origin frame */
445+ }
446+ for ( let i = 0 ; i < win . frames . length ; i ++ ) {
447+ const found = visit ( win . frames [ i ] ) ;
448+ if ( found ) return found ;
449+ }
450+ return null ;
451+ } ;
452+ return visit ( window ) ;
453+ } ;
454+ const start = Date . now ( ) ;
455+ let api = findApi ( ) ;
456+ while ( ( ! api || ! api . isDocumentLoadComplete ) && Date . now ( ) - start < 60_000 ) {
457+ await new Promise ( ( r ) => setTimeout ( r , 500 ) ) ;
458+ api = api || findApi ( ) ;
459+ }
460+ api . AddImageUrlAction ( `${ location . origin } /img/64.png` ) ;
461+ await new Promise ( ( r ) => setTimeout ( r , 6000 ) ) ;
462+
463+ const saved = await Promise . race ( [
464+ post ( 'document:save' , { targetExt : 'DOCX' } ) ,
465+ new Promise < never > ( ( _ , rej ) =>
466+ setTimeout (
467+ ( ) => rej ( new Error ( 'image save timed out (the historical symptom was a permanent hang)' ) ) ,
468+ 60_000 ,
469+ ) ,
470+ ) ,
471+ ] ) ;
472+ const bytes = new Uint8Array ( await ( saved as any ) . file . arrayBuffer ( ) ) ;
473+ const dec = new TextDecoder ( ) ;
474+ const mediaEntries : Array < { name : string ; size : number } > = [ ] ;
475+ for ( let i = 0 ; i < bytes . length - 4 ; i ++ ) {
476+ if ( bytes [ i ] === 0x50 && bytes [ i + 1 ] === 0x4b && bytes [ i + 2 ] === 0x01 && bytes [ i + 3 ] === 0x02 ) {
477+ const nameLen = bytes [ i + 28 ] | ( bytes [ i + 29 ] << 8 ) ;
478+ const size = bytes [ i + 24 ] | ( bytes [ i + 25 ] << 8 ) | ( bytes [ i + 26 ] << 16 ) | ( bytes [ i + 27 ] << 24 ) ;
479+ const name = dec . decode ( bytes . slice ( i + 46 , i + 46 + nameLen ) ) ;
480+ if ( / m e d i a \/ / i. test ( name ) ) mediaEntries . push ( { name, size } ) ;
481+ }
482+ }
483+ return {
484+ savedName : ( saved as any ) . file . name as string ,
485+ isZip : bytes [ 0 ] === 0x50 && bytes [ 1 ] === 0x4b ,
486+ mediaEntries,
487+ } ;
488+ } ) ;
489+
490+ expect ( result . isZip ) . toBe ( true ) ;
491+ expect ( result . savedName ) . toBe ( 'imgsave.docx' ) ;
492+ expect ( result . mediaEntries . length ) . toBeGreaterThan ( 0 ) ;
493+ expect ( result . mediaEntries [ 0 ] . size ) . toBeGreaterThan ( 100 ) ;
494+ } ) ;
495+
333496 test ( 'opens a PDF through the vendor pdf editor' , async ( { page } ) => {
334497 await page . evaluate ( async ( ) => {
335498 // Build a minimal but structurally valid single-page PDF, computing the
0 commit comments