@@ -6442,6 +6442,135 @@ describe('ImageFragment (block-level images)', () => {
64426442 expect ( metadataAttr ) . toBeTruthy ( ) ;
64436443 } ) ;
64446444 } ) ;
6445+
6446+ describe ( 'hyperlink (DrawingML a:hlinkClick)' , ( ) => {
6447+ const makePainter = ( hyperlink ?: { url : string ; tooltip ?: string } ) => {
6448+ const block : FlowBlock = {
6449+ kind : 'image' ,
6450+ id : 'linked-img' ,
6451+ src : 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==' ,
6452+ width : 100 ,
6453+ height : 50 ,
6454+ ...( hyperlink ? { hyperlink } : { } ) ,
6455+ } ;
6456+ const measure : Measure = { kind : 'image' , width : 100 , height : 50 } ;
6457+ const fragment = {
6458+ kind : 'image' as const ,
6459+ blockId : 'linked-img' ,
6460+ x : 20 ,
6461+ y : 20 ,
6462+ width : 100 ,
6463+ height : 50 ,
6464+ } ;
6465+ const layout : Layout = {
6466+ pageSize : { w : 400 , h : 300 } ,
6467+ pages : [ { number : 1 , fragments : [ fragment ] } ] ,
6468+ } ;
6469+ return createDomPainter ( { blocks : [ block ] , measures : [ measure ] } ) ;
6470+ } ;
6471+
6472+ it ( 'wraps linked image in <a class="superdoc-link"> with correct href' , ( ) => {
6473+ const painter = makePainter ( { url : 'https://example.com' } ) ;
6474+ const layout : Layout = {
6475+ pageSize : { w : 400 , h : 300 } ,
6476+ pages : [
6477+ {
6478+ number : 1 ,
6479+ fragments : [
6480+ {
6481+ kind : 'image' as const ,
6482+ blockId : 'linked-img' ,
6483+ x : 20 ,
6484+ y : 20 ,
6485+ width : 100 ,
6486+ height : 50 ,
6487+ } ,
6488+ ] ,
6489+ } ,
6490+ ] ,
6491+ } ;
6492+ painter . paint ( layout , mount ) ;
6493+
6494+ const fragmentEl = mount . querySelector ( '.superdoc-image-fragment' ) ;
6495+ expect ( fragmentEl ) . toBeTruthy ( ) ;
6496+
6497+ const anchor = fragmentEl ?. querySelector ( 'a.superdoc-link' ) as HTMLAnchorElement | null ;
6498+ expect ( anchor ) . toBeTruthy ( ) ;
6499+ expect ( anchor ?. href ) . toBe ( 'https://example.com/' ) ;
6500+ expect ( anchor ?. target ) . toBe ( '_blank' ) ;
6501+ expect ( anchor ?. rel ) . toContain ( 'noopener' ) ;
6502+ expect ( anchor ?. getAttribute ( 'role' ) ) . toBe ( 'link' ) ;
6503+ } ) ;
6504+
6505+ it ( 'sets tooltip as title attribute when present' , ( ) => {
6506+ const block : FlowBlock = {
6507+ kind : 'image' ,
6508+ id : 'tip-img' ,
6509+ src : 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==' ,
6510+ width : 100 ,
6511+ height : 50 ,
6512+ hyperlink : { url : 'https://example.com' , tooltip : 'Go here' } ,
6513+ } ;
6514+ const measure : Measure = { kind : 'image' , width : 100 , height : 50 } ;
6515+ const fragment = { kind : 'image' as const , blockId : 'tip-img' , x : 0 , y : 0 , width : 100 , height : 50 } ;
6516+ const layout : Layout = {
6517+ pageSize : { w : 400 , h : 300 } ,
6518+ pages : [ { number : 1 , fragments : [ fragment ] } ] ,
6519+ } ;
6520+ const painter = createDomPainter ( { blocks : [ block ] , measures : [ measure ] } ) ;
6521+ painter . paint ( layout , mount ) ;
6522+
6523+ const anchor = mount . querySelector ( 'a.superdoc-link' ) as HTMLAnchorElement | null ;
6524+ expect ( anchor ?. title ) . toBe ( 'Go here' ) ;
6525+ } ) ;
6526+
6527+ it ( 'does NOT wrap unlinked image in anchor' , ( ) => {
6528+ const block : FlowBlock = {
6529+ kind : 'image' ,
6530+ id : 'plain-img' ,
6531+ src : 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==' ,
6532+ width : 100 ,
6533+ height : 50 ,
6534+ } ;
6535+ const measure : Measure = { kind : 'image' , width : 100 , height : 50 } ;
6536+ const fragment = { kind : 'image' as const , blockId : 'plain-img' , x : 0 , y : 0 , width : 100 , height : 50 } ;
6537+ const layout : Layout = {
6538+ pageSize : { w : 400 , h : 300 } ,
6539+ pages : [ { number : 1 , fragments : [ fragment ] } ] ,
6540+ } ;
6541+ const painter = createDomPainter ( { blocks : [ block ] , measures : [ measure ] } ) ;
6542+ painter . paint ( layout , mount ) ;
6543+
6544+ const anchor = mount . querySelector ( 'a.superdoc-link' ) ;
6545+ expect ( anchor ) . toBeNull ( ) ;
6546+
6547+ // Image element should still be present
6548+ const img = mount . querySelector ( '.superdoc-image-fragment img' ) ;
6549+ expect ( img ) . toBeTruthy ( ) ;
6550+ } ) ;
6551+
6552+ it ( 'does NOT wrap image when hyperlink URL fails sanitization' , ( ) => {
6553+ const block : FlowBlock = {
6554+ kind : 'image' ,
6555+ id : 'unsafe-img' ,
6556+ src : 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==' ,
6557+ width : 100 ,
6558+ height : 50 ,
6559+ hyperlink : { url : 'javascript:alert(1)' } ,
6560+ } ;
6561+ const measure : Measure = { kind : 'image' , width : 100 , height : 50 } ;
6562+ const fragment = { kind : 'image' as const , blockId : 'unsafe-img' , x : 0 , y : 0 , width : 100 , height : 50 } ;
6563+ const layout : Layout = {
6564+ pageSize : { w : 400 , h : 300 } ,
6565+ pages : [ { number : 1 , fragments : [ fragment ] } ] ,
6566+ } ;
6567+ const painter = createDomPainter ( { blocks : [ block ] , measures : [ measure ] } ) ;
6568+ painter . paint ( layout , mount ) ;
6569+
6570+ const anchor = mount . querySelector ( 'a.superdoc-link' ) ;
6571+ expect ( anchor ) . toBeNull ( ) ;
6572+ } ) ;
6573+ } ) ;
64456574} ) ;
64466575
64476576describe ( 'URL sanitization security' , ( ) => {
0 commit comments