@@ -7507,6 +7507,135 @@ describe('ImageFragment (block-level images)', () => {
75077507 expect ( metadataAttr ) . toBeTruthy ( ) ;
75087508 } ) ;
75097509 } ) ;
7510+
7511+ describe ( 'hyperlink (DrawingML a:hlinkClick)' , ( ) => {
7512+ const makePainter = ( hyperlink ?: { url : string ; tooltip ?: string } ) => {
7513+ const block : FlowBlock = {
7514+ kind : 'image' ,
7515+ id : 'linked-img' ,
7516+ src : 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==' ,
7517+ width : 100 ,
7518+ height : 50 ,
7519+ ...( hyperlink ? { hyperlink } : { } ) ,
7520+ } ;
7521+ const measure : Measure = { kind : 'image' , width : 100 , height : 50 } ;
7522+ const fragment = {
7523+ kind : 'image' as const ,
7524+ blockId : 'linked-img' ,
7525+ x : 20 ,
7526+ y : 20 ,
7527+ width : 100 ,
7528+ height : 50 ,
7529+ } ;
7530+ const layout : Layout = {
7531+ pageSize : { w : 400 , h : 300 } ,
7532+ pages : [ { number : 1 , fragments : [ fragment ] } ] ,
7533+ } ;
7534+ return createDomPainter ( { blocks : [ block ] , measures : [ measure ] } ) ;
7535+ } ;
7536+
7537+ it ( 'wraps linked image in <a class="superdoc-link"> with correct href' , ( ) => {
7538+ const painter = makePainter ( { url : 'https://example.com' } ) ;
7539+ const layout : Layout = {
7540+ pageSize : { w : 400 , h : 300 } ,
7541+ pages : [
7542+ {
7543+ number : 1 ,
7544+ fragments : [
7545+ {
7546+ kind : 'image' as const ,
7547+ blockId : 'linked-img' ,
7548+ x : 20 ,
7549+ y : 20 ,
7550+ width : 100 ,
7551+ height : 50 ,
7552+ } ,
7553+ ] ,
7554+ } ,
7555+ ] ,
7556+ } ;
7557+ painter . paint ( layout , mount ) ;
7558+
7559+ const fragmentEl = mount . querySelector ( '.superdoc-image-fragment' ) ;
7560+ expect ( fragmentEl ) . toBeTruthy ( ) ;
7561+
7562+ const anchor = fragmentEl ?. querySelector ( 'a.superdoc-link' ) as HTMLAnchorElement | null ;
7563+ expect ( anchor ) . toBeTruthy ( ) ;
7564+ expect ( anchor ?. href ) . toBe ( 'https://example.com/' ) ;
7565+ expect ( anchor ?. target ) . toBe ( '_blank' ) ;
7566+ expect ( anchor ?. rel ) . toContain ( 'noopener' ) ;
7567+ expect ( anchor ?. getAttribute ( 'role' ) ) . toBe ( 'link' ) ;
7568+ } ) ;
7569+
7570+ it ( 'sets tooltip as title attribute when present' , ( ) => {
7571+ const block : FlowBlock = {
7572+ kind : 'image' ,
7573+ id : 'tip-img' ,
7574+ src : 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==' ,
7575+ width : 100 ,
7576+ height : 50 ,
7577+ hyperlink : { url : 'https://example.com' , tooltip : 'Go here' } ,
7578+ } ;
7579+ const measure : Measure = { kind : 'image' , width : 100 , height : 50 } ;
7580+ const fragment = { kind : 'image' as const , blockId : 'tip-img' , x : 0 , y : 0 , width : 100 , height : 50 } ;
7581+ const layout : Layout = {
7582+ pageSize : { w : 400 , h : 300 } ,
7583+ pages : [ { number : 1 , fragments : [ fragment ] } ] ,
7584+ } ;
7585+ const painter = createDomPainter ( { blocks : [ block ] , measures : [ measure ] } ) ;
7586+ painter . paint ( layout , mount ) ;
7587+
7588+ const anchor = mount . querySelector ( 'a.superdoc-link' ) as HTMLAnchorElement | null ;
7589+ expect ( anchor ?. title ) . toBe ( 'Go here' ) ;
7590+ } ) ;
7591+
7592+ it ( 'does NOT wrap unlinked image in anchor' , ( ) => {
7593+ const block : FlowBlock = {
7594+ kind : 'image' ,
7595+ id : 'plain-img' ,
7596+ src : 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==' ,
7597+ width : 100 ,
7598+ height : 50 ,
7599+ } ;
7600+ const measure : Measure = { kind : 'image' , width : 100 , height : 50 } ;
7601+ const fragment = { kind : 'image' as const , blockId : 'plain-img' , x : 0 , y : 0 , width : 100 , height : 50 } ;
7602+ const layout : Layout = {
7603+ pageSize : { w : 400 , h : 300 } ,
7604+ pages : [ { number : 1 , fragments : [ fragment ] } ] ,
7605+ } ;
7606+ const painter = createDomPainter ( { blocks : [ block ] , measures : [ measure ] } ) ;
7607+ painter . paint ( layout , mount ) ;
7608+
7609+ const anchor = mount . querySelector ( 'a.superdoc-link' ) ;
7610+ expect ( anchor ) . toBeNull ( ) ;
7611+
7612+ // Image element should still be present
7613+ const img = mount . querySelector ( '.superdoc-image-fragment img' ) ;
7614+ expect ( img ) . toBeTruthy ( ) ;
7615+ } ) ;
7616+
7617+ it ( 'does NOT wrap image when hyperlink URL fails sanitization' , ( ) => {
7618+ const block : FlowBlock = {
7619+ kind : 'image' ,
7620+ id : 'unsafe-img' ,
7621+ src : 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==' ,
7622+ width : 100 ,
7623+ height : 50 ,
7624+ hyperlink : { url : 'javascript:alert(1)' } ,
7625+ } ;
7626+ const measure : Measure = { kind : 'image' , width : 100 , height : 50 } ;
7627+ const fragment = { kind : 'image' as const , blockId : 'unsafe-img' , x : 0 , y : 0 , width : 100 , height : 50 } ;
7628+ const layout : Layout = {
7629+ pageSize : { w : 400 , h : 300 } ,
7630+ pages : [ { number : 1 , fragments : [ fragment ] } ] ,
7631+ } ;
7632+ const painter = createDomPainter ( { blocks : [ block ] , measures : [ measure ] } ) ;
7633+ painter . paint ( layout , mount ) ;
7634+
7635+ const anchor = mount . querySelector ( 'a.superdoc-link' ) ;
7636+ expect ( anchor ) . toBeNull ( ) ;
7637+ } ) ;
7638+ } ) ;
75107639} ) ;
75117640
75127641describe ( 'URL sanitization security' , ( ) => {
0 commit comments