@@ -7,6 +7,7 @@ import type {
77 ImageElement ,
88 LineElement ,
99 TableElement ,
10+ CellBorderSide ,
1011 IconElement ,
1112 EmbedElement ,
1213 ChartElement ,
@@ -134,10 +135,15 @@ function TextView({
134135 ? "center"
135136 : "flex-end" ,
136137 background : el . background ,
138+ // Border / radius for a text-bearing preset shape (e.g. roundRect callout).
139+ border : el . borderColor
140+ ? `${ el . borderWidth ?? 1 } px solid ${ el . borderColor } `
141+ : undefined ,
142+ borderRadius : el . borderRadius ? el . borderRadius : undefined ,
137143 padding : el . padding
138144 ? `${ el . padding . t } px ${ el . padding . r } px ${ el . padding . b } px ${ el . padding . l } px`
139145 : undefined ,
140- boxSizing : el . padding ? "border-box" : undefined ,
146+ boxSizing : el . padding || el . borderColor ? "border-box" : undefined ,
141147 cursor : editing ? "text" : "inherit" ,
142148 } ;
143149 const inner : React . CSSProperties = {
@@ -154,8 +160,8 @@ function TextView({
154160 textAlign : el . align ,
155161 lineHeight : el . lineHeight ,
156162 letterSpacing : el . letterSpacing ,
157- whiteSpace : "pre-wrap" ,
158- wordBreak : "break-word" ,
163+ whiteSpace : el . noWrap ? "pre" : "pre-wrap" ,
164+ wordBreak : el . noWrap ? "normal" : "break-word" ,
159165 outline : "none" ,
160166 } ;
161167
@@ -187,6 +193,9 @@ function TextView({
187193 d = { backingPath . d }
188194 fill = { backingPaint . paint }
189195 fillRule = { backingPath . fillRule ?? "nonzero" }
196+ stroke = { backingPath . stroke }
197+ strokeWidth = { backingPath . stroke ? backingPath . strokeWidth ?? 1 : undefined }
198+ vectorEffect = { backingPath . stroke ? "non-scaling-stroke" : undefined }
190199 />
191200 </ svg >
192201 ) : null ;
@@ -216,20 +225,38 @@ function TextView({
216225 { backingSvg }
217226 < div style = { innerStacked } >
218227 { el . paragraphs . map ( ( pp , pi ) => {
228+ // Indent / spacing live on the per-line blocks below; the wrapper
229+ // only carries alignment (inherited by its line children).
219230 const paraStyle : React . CSSProperties = {
220- paddingLeft : pp . marL ? pp . marL : undefined ,
221- textIndent : pp . indent ? pp . indent : undefined ,
222231 textAlign : pp . align ?? undefined ,
223- marginTop : pp . spaceBefore ? pp . spaceBefore : undefined ,
224232 } ;
225- const content =
233+ // A hanging-indent paragraph needs each line as its own block —
234+ // CSS text-indent only affects a block's first line, so a
235+ // multi-line bulleted paragraph would misalign every bullet after
236+ // the first. Split on "\n" and render one indented block per line.
237+ const lineRuns : TextRun [ ] [ ] =
226238 pp . runs && pp . runs . length
227- ? pp . runs . map ( ( r , ri ) => (
228- < span key = { ri } style = { runCssStyle ( r ) } >
229- { r . text }
230- </ span >
231- ) )
232- : pp . text ;
239+ ? splitRunsByNewline ( pp . runs )
240+ : ( pp . text ?? "" ) . split ( "\n" ) . map ( ( t ) => [ { text : t } ] ) ;
241+ const content = lineRuns . map ( ( line , li ) => (
242+ < div
243+ key = { li }
244+ style = { {
245+ paddingLeft : pp . marL ? pp . marL : undefined ,
246+ textIndent : pp . indent ? pp . indent : undefined ,
247+ marginTop :
248+ li === 0 && pp . spaceBefore ? pp . spaceBefore : undefined ,
249+ } }
250+ >
251+ { line . some ( ( r ) => r . text . length > 0 )
252+ ? line . map ( ( r , ri ) => (
253+ < span key = { ri } style = { runCssStyle ( r ) } >
254+ { r . text }
255+ </ span >
256+ ) )
257+ : /* keep an empty paragraph's line height (blank line) */ " " }
258+ </ div >
259+ ) ) ;
233260 return (
234261 < div key = { pi } style = { paraStyle } >
235262 { content || " " }
@@ -288,13 +315,37 @@ function withGenericFallback(family: string | undefined): string | undefined {
288315 return `${ family } , sans-serif` ;
289316}
290317
318+ /**
319+ * Split a run list into per-line groups at "\n", preserving each run's style.
320+ * Used so a hanging-indent paragraph can render each line as its own block.
321+ */
322+ function splitRunsByNewline ( runs : TextRun [ ] ) : TextRun [ ] [ ] {
323+ const lines : TextRun [ ] [ ] = [ [ ] ] ;
324+ for ( const r of runs ) {
325+ const parts = r . text . split ( "\n" ) ;
326+ parts . forEach ( ( part , i ) => {
327+ if ( i > 0 ) lines . push ( [ ] ) ;
328+ if ( part . length ) lines [ lines . length - 1 ] . push ( { ...r , text : part } ) ;
329+ } ) ;
330+ }
331+ return lines ;
332+ }
333+
291334function runCssStyle ( r : TextRun ) : React . CSSProperties {
292335 const s : React . CSSProperties = { } ;
293336 if ( r . fontFamily ) s . fontFamily = withGenericFallback ( r . fontFamily ) ;
294337 if ( r . fontSize ) s . fontSize = r . fontSize ;
295338 if ( r . fontWeight ) s . fontWeight = r . fontWeight ;
296339 if ( r . color ) s . color = r . color ;
340+ if ( r . highlight ) {
341+ s . backgroundColor = r . highlight ;
342+ // Keep the highlight painted continuously across wrapped lines.
343+ s . boxDecorationBreak = "clone" ;
344+ s . WebkitBoxDecorationBreak = "clone" ;
345+ }
297346 if ( r . italic ) s . fontStyle = "italic" ;
347+ if ( r . cap === "all" ) s . textTransform = "uppercase" ;
348+ else if ( r . cap === "small" ) s . fontVariant = "small-caps" ;
298349 if ( r . letterSpacing != null ) s . letterSpacing = r . letterSpacing ;
299350 const decoration = [ r . underline && "underline" , r . strike && "line-through" ]
300351 . filter ( Boolean )
@@ -389,6 +440,9 @@ function runsToHtml(runs: TextRun[]): string {
389440 if ( r . fontSize ) props . push ( `font-size: ${ r . fontSize } px` ) ;
390441 if ( r . fontWeight ) props . push ( `font-weight: ${ r . fontWeight } ` ) ;
391442 if ( r . italic ) props . push ( `font-style: italic` ) ;
443+ if ( r . cap === "all" ) props . push ( `text-transform: uppercase` ) ;
444+ else if ( r . cap === "small" ) props . push ( `font-variant: small-caps` ) ;
445+ if ( r . highlight ) props . push ( `background-color: ${ r . highlight } ` ) ;
392446 if ( r . letterSpacing != null ) props . push ( `letter-spacing: ${ r . letterSpacing } px` ) ;
393447 const decoration = [ r . underline && "underline" , r . strike && "line-through" ]
394448 . filter ( Boolean )
@@ -417,6 +471,9 @@ function styleToRun(el: HTMLElement, text: string): TextRun {
417471 if ( Number . isFinite ( w ) ) r . fontWeight = w ;
418472 }
419473 if ( s . fontStyle === "italic" ) r . italic = true ;
474+ if ( s . textTransform === "uppercase" ) r . cap = "all" ;
475+ else if ( s . fontVariant === "small-caps" ) r . cap = "small" ;
476+ if ( s . backgroundColor ) r . highlight = s . backgroundColor ;
420477 if ( s . letterSpacing ) {
421478 const ls = parseFloat ( s . letterSpacing ) ;
422479 if ( Number . isFinite ( ls ) ) r . letterSpacing = ls ;
@@ -477,6 +534,8 @@ function sameStyle(a: TextRun, b: TextRun): boolean {
477534 a . italic === b . italic &&
478535 a . underline === b . underline &&
479536 a . strike === b . strike &&
537+ a . highlight === b . highlight &&
538+ a . cap === b . cap &&
480539 a . letterSpacing === b . letterSpacing
481540 ) ;
482541}
@@ -965,6 +1024,15 @@ function TableView({ el }: { el: TableElement }) {
9651024 const hasHeader = el . hasHeader ?? true ;
9661025 const bandRows = el . bandRows ?? false ;
9671026 const cellFill = ( ri : number , ci : number ) : string => {
1027+ // An explicit per-cell fill (PPTX <a:tcPr> override) wins over every
1028+ // row-class default — this is what paints think-cell Gantt cells.
1029+ const perCell = el . cellFills ?. [ ri ] ?. [ ci ] ;
1030+ if ( perCell ) return perCell ;
1031+ // In a per-cell-fill table, a cell with no fill of its own is transparent
1032+ // (the slide shows through). It must NOT fall back to headerFill/rowFill —
1033+ // those were derived from some other cell and would flood unfilled cells
1034+ // with that colour (e.g. a stray cream band turning the whole grid cream).
1035+ if ( el . cellFills ) return "transparent" ;
9681036 if ( hasHeader && ri === 0 ) return el . headerFill ;
9691037 if ( el . lastRowFill && ri === rowCount - 1 && rowCount > 1 ) return el . lastRowFill ;
9701038 if ( el . firstColFill && ci === 0 ) return el . firstColFill ;
@@ -978,27 +1046,98 @@ function TableView({ el }: { el: TableElement }) {
9781046 return el . rowFill ;
9791047 } ;
9801048 const cellColor = ( ri : number , ci : number ) : string => {
1049+ const perCell = el . cellTextColors ?. [ ri ] ?. [ ci ] ;
1050+ if ( perCell ) return perCell ;
9811051 if ( hasHeader && ri === 0 && el . headerTextColor ) return el . headerTextColor ;
9821052 if ( el . firstColTextColor && ci === 0 && ! ( hasHeader && ri === 0 ) ) {
9831053 return el . firstColTextColor ;
9841054 }
9851055 return el . textColor ;
9861056 } ;
1057+
1058+ // When the source defined per-cell borders, honour them exactly: most PPTX
1059+ // (think-cell) cells leave sides blank, so a uniform grid is wrong. Each
1060+ // internal edge is drawn once — by the cell above (its bottom) or to the
1061+ // left (its right) — and a coloured side wins over a neighbour's blank one,
1062+ // so shared edges never double up.
1063+ const hasCellBorders = ! ! el . cellBorders ;
1064+ const sideCss = ( s : CellBorderSide | null | undefined ) : string | undefined =>
1065+ s ? `${ s . width } px solid ${ s . color } ` : undefined ;
1066+ // Pick the drawn line between two adjacent sides (a colour beats null/absent).
1067+ const mergeSide = (
1068+ a : CellBorderSide | null | undefined ,
1069+ b : CellBorderSide | null | undefined
1070+ ) : CellBorderSide | null | undefined => a ?? b ;
1071+ // Merged cells: a covered continuation cell renders nothing, and a spanning
1072+ // origin cell is placed explicitly so it covers the columns/rows it merges
1073+ // (e.g. a full-width band). Explicit placement (col/row = array index) avoids
1074+ // auto-flow ambiguity once some cells span and others are omitted.
1075+ const hasSpans = ! ! el . cellSpans ;
1076+ const cellPlacement = ( ri : number , ci : number ) : React . CSSProperties => {
1077+ if ( ! hasSpans ) return { } ;
1078+ const span = el . cellSpans ?. [ ri ] ?. [ ci ] ;
1079+ return {
1080+ gridColumn : `${ ci + 1 } / span ${ span ?. colSpan ?? 1 } ` ,
1081+ gridRow : `${ ri + 1 } / span ${ span ?. rowSpan ?? 1 } ` ,
1082+ } ;
1083+ } ;
1084+ const cellBorderStyle = ( ri : number , ci : number ) : React . CSSProperties => {
1085+ if ( ! hasCellBorders ) {
1086+ // Legacy default: a single faint grid line shared between cells.
1087+ return {
1088+ borderRight : ci < cols - 1 ? `1px solid ${ stroke } ` : undefined ,
1089+ borderBottom : ri < rowCount - 1 ? `1px solid ${ stroke } ` : undefined ,
1090+ } ;
1091+ }
1092+ const cb = el . cellBorders ?. [ ri ] ?. [ ci ] ?? undefined ;
1093+ const right = mergeSide ( cb ?. r , el . cellBorders ?. [ ri ] ?. [ ci + 1 ] ?. l ) ;
1094+ const bottom = mergeSide ( cb ?. b , el . cellBorders ?. [ ri + 1 ] ?. [ ci ] ?. t ) ;
1095+ return {
1096+ // Outer top/left edges belong to the first row/column; internal top/left
1097+ // edges are covered by the neighbour's bottom/right so they aren't doubled.
1098+ borderTop : ri === 0 ? sideCss ( cb ?. t ) : undefined ,
1099+ borderLeft : ci === 0 ? sideCss ( cb ?. l ) : undefined ,
1100+ borderRight : sideCss ( right ) ,
1101+ borderBottom : sideCss ( bottom ) ,
1102+ } ;
1103+ } ;
9871104 return (
9881105 < div
9891106 style = { {
9901107 display : "grid" ,
991- gridTemplateColumns : `repeat(${ cols } , 1fr)` ,
992- gridAutoRows : "1fr" ,
1108+ gridTemplateColumns :
1109+ el . colWidths && el . colWidths . length === cols
1110+ ? el . colWidths . map ( ( w ) => `${ w } fr` ) . join ( " " )
1111+ : `repeat(${ cols } , 1fr)` ,
1112+ gridTemplateRows :
1113+ el . rowHeights && el . rowHeights . length === rowCount
1114+ ? el . rowHeights . map ( ( h ) => `${ h } fr` ) . join ( " " )
1115+ : `repeat(${ rowCount } , 1fr)` ,
9931116 width : "100%" ,
9941117 height : "100%" ,
9951118 gap : 0 ,
9961119 background : "transparent" ,
997- boxShadow : `inset 0 0 0 1px ${ stroke } ` ,
1120+ // The legacy frame only applies to tables without explicit cell borders.
1121+ boxShadow : hasCellBorders ? undefined : `inset 0 0 0 1px ${ stroke } ` ,
9981122 } }
9991123 >
10001124 { el . rows . flatMap ( ( row , ri ) =>
1001- row . map ( ( cell , ci ) => (
1125+ row . map ( ( cell , ci ) => {
1126+ // Cells merged into a neighbour aren't rendered — the spanning
1127+ // origin covers their grid slot.
1128+ if ( el . cellSpans ?. [ ri ] ?. [ ci ] ?. covered ) return null ;
1129+ // Rich runs (highlight / per-run font / bullet line breaks / ✓
1130+ // glyphs) take over from the flat string when present.
1131+ const runs = el . cellRuns ?. [ ri ] ?. [ ci ] ;
1132+ const content =
1133+ runs && runs . length
1134+ ? runs . map ( ( r , i ) => (
1135+ < span key = { i } style = { runCssStyle ( r ) } >
1136+ { r . text }
1137+ </ span >
1138+ ) )
1139+ : cell ;
1140+ return (
10021141 < div
10031142 key = { `${ ri } -${ ci } ` }
10041143 style = { {
@@ -1007,7 +1146,16 @@ function TableView({ el }: { el: TableElement }) {
10071146 fontSize : el . fontSize ,
10081147 padding : "12px 16px" ,
10091148 display : "flex" ,
1010- alignItems : "center" ,
1149+ // Vertical alignment: honour the cell's own anchor when the
1150+ // source set one (<a:tcPr anchor>); otherwise fall back to the
1151+ // header-centred / body-top default. PPTX cells default to top.
1152+ alignItems : ( ( ) => {
1153+ const va = el . cellVAligns ?. [ ri ] ?. [ ci ] ;
1154+ if ( va === "middle" ) return "center" ;
1155+ if ( va === "bottom" ) return "flex-end" ;
1156+ if ( va === "top" ) return "flex-start" ;
1157+ return hasHeader && ri === 0 ? "center" : "flex-start" ;
1158+ } ) ( ) ,
10111159 fontWeight :
10121160 ( hasHeader && ri === 0 ) || ( el . firstColFill && ci === 0 )
10131161 ? 600
@@ -1017,15 +1165,19 @@ function TableView({ el }: { el: TableElement }) {
10171165 minHeight : 0 ,
10181166 overflow : "hidden" ,
10191167 wordBreak : "break-word" ,
1020- borderRight :
1021- ci < cols - 1 ? `1px solid ${ stroke } ` : undefined ,
1022- borderBottom :
1023- ri < rowCount - 1 ? `1px solid ${ stroke } ` : undefined ,
1168+ ...cellPlacement ( ri , ci ) ,
1169+ ...cellBorderStyle ( ri , ci ) ,
10241170 } }
10251171 >
1026- { cell }
1172+ { /* Single inline-flow child so run spans wrap as text and the
1173+ "\n" bullet breaks apply (flex children would lay out in a
1174+ row, collapsing every bullet onto one line). */ }
1175+ < div style = { { width : "100%" , whiteSpace : "pre-wrap" } } >
1176+ { content }
1177+ </ div >
10271178 </ div >
1028- ) )
1179+ ) ;
1180+ } )
10291181 ) }
10301182 </ div >
10311183 ) ;
0 commit comments