@@ -6050,6 +6050,58 @@ describe('DomPainter', () => {
60506050 } ) ;
60516051
60526052 describe ( 'renderImageRun (inline image runs)' , ( ) => {
6053+ const renderInlineImageRun = (
6054+ run : Extract < FlowBlock , { kind : 'paragraph' } > [ 'runs' ] [ number ] ,
6055+ lineWidth = 100 ,
6056+ lineHeight = 100 ,
6057+ ) => {
6058+ const imageBlock : FlowBlock = {
6059+ kind : 'paragraph' ,
6060+ id : 'img-block' ,
6061+ runs : [ run ] ,
6062+ } ;
6063+
6064+ const imageMeasure : Measure = {
6065+ kind : 'paragraph' ,
6066+ lines : [
6067+ {
6068+ fromRun : 0 ,
6069+ fromChar : 0 ,
6070+ toRun : 0 ,
6071+ toChar : 0 ,
6072+ width : lineWidth ,
6073+ ascent : lineHeight ,
6074+ descent : 0 ,
6075+ lineHeight,
6076+ } ,
6077+ ] ,
6078+ totalHeight : lineHeight ,
6079+ } ;
6080+
6081+ const imageLayout : Layout = {
6082+ pageSize : { w : 400 , h : 500 } ,
6083+ pages : [
6084+ {
6085+ number : 1 ,
6086+ fragments : [
6087+ {
6088+ kind : 'para' ,
6089+ blockId : 'img-block' ,
6090+ fromLine : 0 ,
6091+ toLine : 1 ,
6092+ x : 0 ,
6093+ y : 0 ,
6094+ width : lineWidth ,
6095+ } ,
6096+ ] ,
6097+ } ,
6098+ ] ,
6099+ } ;
6100+
6101+ const painter = createDomPainter ( { blocks : [ imageBlock ] , measures : [ imageMeasure ] } ) ;
6102+ painter . paint ( imageLayout , mount ) ;
6103+ } ;
6104+
60536105 it ( 'renders img element with valid data URL' , ( ) => {
60546106 const imageBlock : FlowBlock = {
60556107 kind : 'paragraph' ,
@@ -6518,6 +6570,84 @@ describe('DomPainter', () => {
65186570 expect ( img ) . toBeNull ( ) ;
65196571 } ) ;
65206572
6573+ it ( 'wraps linked inline image in anchor without clipPath' , ( ) => {
6574+ renderInlineImageRun ( {
6575+ kind : 'image' ,
6576+ src : 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==' ,
6577+ width : 100 ,
6578+ height : 100 ,
6579+ title : 'Image' ,
6580+ hyperlink : { url : 'https://example.com/inline' , tooltip : ' Inline tooltip ' } ,
6581+ } ) ;
6582+
6583+ const anchor = mount . querySelector ( 'a.superdoc-link' ) as HTMLAnchorElement | null ;
6584+ const img = anchor ?. querySelector ( 'img' ) as HTMLImageElement | null ;
6585+ expect ( anchor ) . toBeTruthy ( ) ;
6586+ expect ( anchor ?. href ) . toBe ( 'https://example.com/inline' ) ;
6587+ expect ( anchor ?. title ) . toBe ( 'Inline tooltip' ) ;
6588+ expect ( img ?. getAttribute ( 'title' ) ) . toBeNull ( ) ;
6589+ expect ( anchor ?. firstElementChild ?. tagName ) . toBe ( 'IMG' ) ;
6590+ } ) ;
6591+
6592+ it ( 'falls back to hyperlink URL for linked inline image title' , ( ) => {
6593+ renderInlineImageRun ( {
6594+ kind : 'image' ,
6595+ src : 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==' ,
6596+ width : 100 ,
6597+ height : 100 ,
6598+ title : 'Image' ,
6599+ hyperlink : { url : 'https://superdoc.dev' } ,
6600+ } ) ;
6601+
6602+ const anchor = mount . querySelector ( 'a.superdoc-link' ) as HTMLAnchorElement | null ;
6603+ const img = anchor ?. querySelector ( 'img' ) as HTMLImageElement | null ;
6604+ expect ( anchor ) . toBeTruthy ( ) ;
6605+ expect ( anchor ?. title ) . toBe ( 'https://superdoc.dev' ) ;
6606+ expect ( img ?. getAttribute ( 'title' ) ) . toBeNull ( ) ;
6607+ } ) ;
6608+
6609+ it ( 'wraps linked inline image clip wrapper in anchor when clipPath uses positive dimensions' , ( ) => {
6610+ renderInlineImageRun (
6611+ {
6612+ kind : 'image' ,
6613+ src : 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==' ,
6614+ width : 80 ,
6615+ height : 60 ,
6616+ clipPath : 'inset(10% 20% 30% 40%)' ,
6617+ hyperlink : { url : 'https://example.com/clip-wrapper' } ,
6618+ } ,
6619+ 80 ,
6620+ 60 ,
6621+ ) ;
6622+
6623+ const anchor = mount . querySelector ( 'a.superdoc-link' ) as HTMLAnchorElement | null ;
6624+ expect ( anchor ) . toBeTruthy ( ) ;
6625+ expect ( anchor ?. querySelector ( '.superdoc-inline-image-clip-wrapper' ) ) . toBeTruthy ( ) ;
6626+ expect ( anchor ?. querySelector ( '.superdoc-inline-image-clip-wrapper img' ) ) . toBeTruthy ( ) ;
6627+ } ) ;
6628+
6629+ it ( 'wraps linked inline image clip wrapper in anchor when clipPath falls back to wrapper return path' , ( ) => {
6630+ renderInlineImageRun (
6631+ {
6632+ kind : 'image' ,
6633+ src : 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==' ,
6634+ width : 0 ,
6635+ height : 60 ,
6636+ clipPath : 'inset(10% 20% 30% 40%)' ,
6637+ hyperlink : { url : 'https://example.com/fallback-wrapper' } ,
6638+ } ,
6639+ 1 ,
6640+ 60 ,
6641+ ) ;
6642+
6643+ const anchor = mount . querySelector ( 'a.superdoc-link' ) as HTMLAnchorElement | null ;
6644+ const wrapper = anchor ?. querySelector ( '.superdoc-inline-image-clip-wrapper' ) as HTMLElement | null ;
6645+ expect ( anchor ) . toBeTruthy ( ) ;
6646+ expect ( wrapper ) . toBeTruthy ( ) ;
6647+ expect ( wrapper ?. style . width ) . toBe ( '0px' ) ;
6648+ expect ( wrapper ?. querySelector ( 'img' ) ) . toBeTruthy ( ) ;
6649+ } ) ;
6650+
65216651 it ( 'renders cropped inline image with clipPath in wrapper (overflow hidden, img with clip-path and transform)' , ( ) => {
65226652 const clipPath = 'inset(10% 20% 30% 40%)' ;
65236653 const imageBlock : FlowBlock = {
@@ -7561,6 +7691,123 @@ describe('ImageFragment (block-level images)', () => {
75617691 expect ( metadataAttr ) . toBeTruthy ( ) ;
75627692 } ) ;
75637693 } ) ;
7694+
7695+ describe ( 'hyperlink (DrawingML a:hlinkClick)' , ( ) => {
7696+ const makePainter = ( hyperlink ?: { url : string ; tooltip ?: string } ) => {
7697+ const block : FlowBlock = {
7698+ kind : 'image' ,
7699+ id : 'linked-img' ,
7700+ src : 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==' ,
7701+ width : 100 ,
7702+ height : 50 ,
7703+ ...( hyperlink ? { hyperlink } : { } ) ,
7704+ } ;
7705+ const measure : Measure = { kind : 'image' , width : 100 , height : 50 } ;
7706+ return createDomPainter ( { blocks : [ block ] , measures : [ measure ] } ) ;
7707+ } ;
7708+
7709+ it ( 'wraps linked image in <a class="superdoc-link"> with correct href' , ( ) => {
7710+ const painter = makePainter ( { url : 'https://example.com' } ) ;
7711+ const layout : Layout = {
7712+ pageSize : { w : 400 , h : 300 } ,
7713+ pages : [
7714+ {
7715+ number : 1 ,
7716+ fragments : [
7717+ {
7718+ kind : 'image' as const ,
7719+ blockId : 'linked-img' ,
7720+ x : 20 ,
7721+ y : 20 ,
7722+ width : 100 ,
7723+ height : 50 ,
7724+ } ,
7725+ ] ,
7726+ } ,
7727+ ] ,
7728+ } ;
7729+ painter . paint ( layout , mount ) ;
7730+
7731+ const fragmentEl = mount . querySelector ( '.superdoc-image-fragment' ) ;
7732+ expect ( fragmentEl ) . toBeTruthy ( ) ;
7733+
7734+ const anchor = fragmentEl ?. querySelector ( 'a.superdoc-link' ) as HTMLAnchorElement | null ;
7735+ expect ( anchor ) . toBeTruthy ( ) ;
7736+ expect ( anchor ?. href ) . toBe ( 'https://example.com/' ) ;
7737+ expect ( anchor ?. target ) . toBe ( '_blank' ) ;
7738+ expect ( anchor ?. rel ) . toContain ( 'noopener' ) ;
7739+ expect ( anchor ?. getAttribute ( 'role' ) ) . toBe ( 'link' ) ;
7740+ } ) ;
7741+
7742+ it ( 'encodes tooltip before setting title attribute' , ( ) => {
7743+ const block : FlowBlock = {
7744+ kind : 'image' ,
7745+ id : 'tip-img' ,
7746+ src : 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==' ,
7747+ width : 100 ,
7748+ height : 50 ,
7749+ hyperlink : { url : 'https://example.com' , tooltip : ` ${ 'x' . repeat ( 600 ) } ` } ,
7750+ } ;
7751+ const measure : Measure = { kind : 'image' , width : 100 , height : 50 } ;
7752+ const fragment = { kind : 'image' as const , blockId : 'tip-img' , x : 0 , y : 0 , width : 100 , height : 50 } ;
7753+ const layout : Layout = {
7754+ pageSize : { w : 400 , h : 300 } ,
7755+ pages : [ { number : 1 , fragments : [ fragment ] } ] ,
7756+ } ;
7757+ const painter = createDomPainter ( { blocks : [ block ] , measures : [ measure ] } ) ;
7758+ painter . paint ( layout , mount ) ;
7759+
7760+ const anchor = mount . querySelector ( 'a.superdoc-link' ) as HTMLAnchorElement | null ;
7761+ expect ( anchor ?. title ) . toBe ( 'x' . repeat ( 500 ) ) ;
7762+ } ) ;
7763+
7764+ it ( 'does NOT wrap unlinked image in anchor' , ( ) => {
7765+ const block : FlowBlock = {
7766+ kind : 'image' ,
7767+ id : 'plain-img' ,
7768+ src : 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==' ,
7769+ width : 100 ,
7770+ height : 50 ,
7771+ } ;
7772+ const measure : Measure = { kind : 'image' , width : 100 , height : 50 } ;
7773+ const fragment = { kind : 'image' as const , blockId : 'plain-img' , x : 0 , y : 0 , width : 100 , height : 50 } ;
7774+ const layout : Layout = {
7775+ pageSize : { w : 400 , h : 300 } ,
7776+ pages : [ { number : 1 , fragments : [ fragment ] } ] ,
7777+ } ;
7778+ const painter = createDomPainter ( { blocks : [ block ] , measures : [ measure ] } ) ;
7779+ painter . paint ( layout , mount ) ;
7780+
7781+ const anchor = mount . querySelector ( 'a.superdoc-link' ) ;
7782+ expect ( anchor ) . toBeNull ( ) ;
7783+
7784+ // Image element should still be present
7785+ const img = mount . querySelector ( '.superdoc-image-fragment img' ) ;
7786+ expect ( img ) . toBeTruthy ( ) ;
7787+ } ) ;
7788+
7789+ it ( 'does NOT wrap image when hyperlink URL fails sanitization' , ( ) => {
7790+ const block : FlowBlock = {
7791+ kind : 'image' ,
7792+ id : 'unsafe-img' ,
7793+ src : 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==' ,
7794+ width : 100 ,
7795+ height : 50 ,
7796+ hyperlink : { url : 'javascript:alert(1)' } ,
7797+ } ;
7798+ const measure : Measure = { kind : 'image' , width : 100 , height : 50 } ;
7799+ const fragment = { kind : 'image' as const , blockId : 'unsafe-img' , x : 0 , y : 0 , width : 100 , height : 50 } ;
7800+ const layout : Layout = {
7801+ pageSize : { w : 400 , h : 300 } ,
7802+ pages : [ { number : 1 , fragments : [ fragment ] } ] ,
7803+ } ;
7804+ const painter = createDomPainter ( { blocks : [ block ] , measures : [ measure ] } ) ;
7805+ painter . paint ( layout , mount ) ;
7806+
7807+ const anchor = mount . querySelector ( 'a.superdoc-link' ) ;
7808+ expect ( anchor ) . toBeNull ( ) ;
7809+ } ) ;
7810+ } ) ;
75647811} ) ;
75657812
75667813describe ( 'URL sanitization security' , ( ) => {
0 commit comments