From 83c294005cd0108ef8ed8542588f6d9fccc3f577 Mon Sep 17 00:00:00 2001 From: Eric Marchand Date: Fri, 23 Oct 2026 11:15:56 +0200 Subject: [PATCH] fix(ACI0104847,ACI0105646): preserve formatting in picture exports [ai] --- .../Sources/Methods/VP Convert to picture.4dm | 108 +++++++-- Project/Sources/Methods/svg_drawCellValue.4dm | 14 +- Project/Sources/Methods/vp_getCellRange.4dm | 18 +- Resources/scripts/commands/file.js | 216 ++++++++++++++++++ 4 files changed, 332 insertions(+), 24 deletions(-) diff --git a/Project/Sources/Methods/VP Convert to picture.4dm b/Project/Sources/Methods/VP Convert to picture.4dm index f3383b5..b9e4701 100644 --- a/Project/Sources/Methods/VP Convert to picture.4dm +++ b/Project/Sources/Methods/VP Convert to picture.4dm @@ -7,12 +7,13 @@ err_TRY ARRAY LONGINT:C221($colPos; 0) ARRAY LONGINT:C221($rowPos; 0) -var $sheets; $table; $cell; $style : Object +var $sheets; $table; $cell; $style; $actualStyles; $actualStyle : Object var $lineObj; $obj; $defaultStyle : Object var $namedStyle; $workbook; $parsedStyle; $bcObj : Object var $strPart : Object var $svgSource; $svgRef; $elemRef : Text var $shrinkStr; $defaultFontSize; $fontSize; $valueStr : Text +var $placeholderLeft; $placeholderRight : Text var $textWidth; $i; $index; $indexColWidth; $indexRowHeight; $rowCount; $colCount; $maxSvgWidth; $maxSvgHeight : Integer var $iterX; $iterY; $svgHeight; $svgWidth; $x1; $x2; $y1; $y2; $height; $width : Integer @@ -22,6 +23,7 @@ var $pos : Integer var $colWidth; $rowHeight : Real var $borderOffset : Real +var $paddingLeft; $paddingRight : Real var $pictWidth; $pictHeight : Real var $pictX; $pictY : Real @@ -116,6 +118,12 @@ End if // MARK:- Default Styles and Theme If ($sheets#Null:C1517) $table:=$sheets.data.dataTable + + // styles of tables and conditional formats computed by SpreadJS (see Utils.addFormatedText) + If (Value type:C1509($sheets.actualStyleInfo)=Is object:K8:27) + $actualStyles:=$sheets.actualStyleInfo + End if + If ($sheets.data.defaultDataNode.style#Null:C1517) Case of @@ -631,9 +639,21 @@ For ($iterY; $range.y1; $range.y2) $parsedStyle.wordWrapInfo:=Null:C1517 $borderOrientation:=False:C215 $borderOffset:=0 + $paddingLeft:=0 + $paddingRight:=0 $cell:=Null:C1517 + // MARK: Table and conditional format style (has priority over the other styles) + $actualStyle:=Null:C1517 + If ($actualStyles#Null:C1517) + If (Value type:C1509($actualStyles[String:C10($iterY)])=Is object:K8:27) + If (Value type:C1509($actualStyles[String:C10($iterY)][String:C10($iterX)])=Is object:K8:27) + $actualStyle:=$actualStyles[String:C10($iterY)][String:C10($iterX)] + svg_parseStyle($actualStyle; $parsedStyle) + End if + End if + End if If ($table#Null:C1517) If ($table[String:C10($iterY)]#Null:C1517) @@ -945,6 +965,13 @@ End if End while End if + // the font color computed by SpreadJS already takes into account the color of the number format + If ($actualStyle#Null:C1517) + If (Value type:C1509($actualStyle.foreColor)=Is text:K8:3) + $parsedStyle.fill:=svg_retrieveColor($actualStyle.foreColor) + End if + End if + // MARK: Ajout de la backgroundImage If ($parsedStyle.backgroundImage#"") | ($parsedStyle.cellTypeName="1") @@ -1253,18 +1280,32 @@ End if : (Value type:C1509($parsedStyle.formatInfo.text)=Is text:K8:3) $valueStr:=$parsedStyle.formatInfo.text + // "_x" and "?" of the number format at the start and at the end: invisible gaps of the width of their chars + If ($parsedStyle.wordWrap#1) & ($parsedStyle.isVerticalText#1) & (($parsedStyle.textOrientation=0) | ($parsedStyle.textOrientation=NONE_TEXT_ORIENTATION)) + If (Value type:C1509($parsedStyle.formatInfo.placeholderLeft)=Is text:K8:3) + $paddingLeft:=svg_getTextWidth($parsedStyle.formatInfo.placeholderLeft; $parsedStyle.fontObj) + End if + If (Value type:C1509($parsedStyle.formatInfo.placeholderRight)=Is text:K8:3) + $paddingRight:=svg_getTextWidth($parsedStyle.formatInfo.placeholderRight; $parsedStyle.fontObj) + End if + End if + If (Value type:C1509($cell.value)#Is text:K8:3) - $w:=svg_getTextWidth($valueStr; $parsedStyle.fontObj) + $w:=svg_getTextWidth($valueStr; $parsedStyle.fontObj)+$paddingLeft+$paddingRight If ($w>(Int:C8($spaceWidth)-3)) & ($parsedStyle.isVerticalText#1) $valueStr:="#"*Int:C8(((Int:C8($spaceWidth)-3)/svg_getTextWidth("#"; $parsedStyle.fontObj))) + $paddingLeft:=0 + $paddingRight:=0 End if Else If ($cell.value="/OADate(@)/") - $w:=svg_getTextWidth($valueStr; $parsedStyle.fontObj) + $w:=svg_getTextWidth($valueStr; $parsedStyle.fontObj)+$paddingLeft+$paddingRight If ($w>(Int:C8($spaceWidth)-3)) & ($parsedStyle.isVerticalText#1) $valueStr:="#"*Int:C8(((Int:C8($spaceWidth)-3)/svg_getTextWidth("#"; $parsedStyle.fontObj))) + $paddingLeft:=0 + $paddingRight:=0 End if End if End if @@ -1276,9 +1317,16 @@ End if $str:="" var $valPart : Object For each ($valPart; $parsedStyle.formatInfo.content) - If ($valPart.type#"fillingChar") && ($valPart.type#"placeHolder") && ($valPart.type#"numberPlaceholder") - $str:=$str+$valPart.value - End if + Case of + : ($valPart.type="fillingChar") + : ($valPart.type="numberPlaceholder") + $str:=$str+"0" + : ($valPart.type="placeholder") + // "_x": invisible gap of the width of x, which reduces the filling + $str:=$str+String:C10($valPart.value) + Else + $str:=$str+$valPart.value + End case End for each var $w : Real $w:=svg_getTextWidth($str; $parsedStyle.fontObj) @@ -1316,23 +1364,41 @@ End if End if $valueStr:="" + $placeholderLeft:="" + $placeholderRight:="" For each ($valPart; $parsedStyle.formatInfo.content) - If ($valPart.type="fillingChar") - $valueStr:=$valueStr+($fc*$nbC) - $strPart:=New object:C1471("part1"; $valueStr; "part2"; "") - Else - If (($valPart.type#"placeHolder") && ($valPart.type#"numberPlaceholder")) + Case of + : ($valPart.type="fillingChar") + $valueStr:=$valueStr+($fc*$nbC) + $strPart:=New object:C1471("part1"; $valueStr; "part2"; "") + + : ($valPart.type="placeholder") | ($valPart.type="numberPlaceholder") + // only the gaps before the text of part1 and after the text of part2 are handled + Case of + : ($strPart#Null:C1517) + $placeholderRight:=$placeholderRight+Choose:C955($valPart.type="numberPlaceholder"; "0"; String:C10($valPart.value)) + : ($valueStr="") + $placeholderLeft:=$placeholderLeft+Choose:C955($valPart.type="numberPlaceholder"; "0"; String:C10($valPart.value)) + End case + + Else $valueStr:=$valueStr+$valPart.value If ($strPart#Null:C1517) $strPart.part2:=$strPart.part2+$valPart.value + $placeholderRight:="" End if - Else - - - End if - End if + End case End for each + If (($parsedStyle.textOrientation=0) | ($parsedStyle.textOrientation=NONE_TEXT_ORIENTATION)) + If ($placeholderLeft#"") + $paddingLeft:=svg_getTextWidth($placeholderLeft; $parsedStyle.fontObj) + End if + If ($placeholderRight#"") & ($parsedStyle.wordWrap#1) + $paddingRight:=svg_getTextWidth($placeholderRight; $parsedStyle.fontObj) + End if + End if + End if End case @@ -1342,9 +1408,9 @@ End if End case If ($strPart#Null:C1517) - $textWidth:=svg_getTextWidth($strPart.part1+$strPart.part2; $parsedStyle.fontObj) + $textWidth:=svg_getTextWidth($strPart.part1+$strPart.part2; $parsedStyle.fontObj)+$paddingLeft+$paddingRight Else - $textWidth:=svg_getTextWidth($valueStr; $parsedStyle.fontObj) + $textWidth:=svg_getTextWidth($valueStr; $parsedStyle.fontObj)+$paddingLeft+$paddingRight End if If ($parsedStyle.shrinkToFit=1) & ($parsedStyle.wordWrap#1) & (Not:C34(($parsedStyle.isVerticalText=1) & (($parsedStyle.vAlign#TOP_VERTICAL_ALIGN) & ($parsedStyle.vAlign#NONE_VERTICAL_ALIGN)))) @@ -1374,6 +1440,11 @@ End if $textWidth:=$height-4 Else $shrinkStr:=String:C10(Num:C11($fontSize; ".")/$textWidth*($width-4); "&xml;")+"px" + // the gaps of the number format are reduced like the font + If ($textWidth>($width-4)) + $paddingLeft:=$paddingLeft*($width-4)/$textWidth + $paddingRight:=$paddingRight*($width-4)/$textWidth + End if $textWidth:=$width-4 End case End if @@ -1521,6 +1592,7 @@ End if "shrinkToFit"; $shrinkStr; \ "textWidth"; $textWidth; \ "strPart"; $strPart; \ + "paddingLeft"; $paddingLeft; "paddingRight"; $paddingRight; \ "textOrientation"; $parsedStyle.textOrientation; \ "borderOrientation"; $borderOrientation; \ "borderOffset"; $borderOffset; \ diff --git a/Project/Sources/Methods/svg_drawCellValue.4dm b/Project/Sources/Methods/svg_drawCellValue.4dm index 2d561eb..affa86e 100644 --- a/Project/Sources/Methods/svg_drawCellValue.4dm +++ b/Project/Sources/Methods/svg_drawCellValue.4dm @@ -938,14 +938,18 @@ End for each If ($valObj.wordWrapInfo=Null:C1517) & (($valObj.textOrientation=0) | ($valObj.textOrientation=NONE_TEXT_ORIENTATION)) Case of - : ($valObj.hAlign=LEFT_HORIZONTAL_ALIGN) | ($valObj.strPart#Null:C1517) + : ($valObj.strPart#Null:C1517) DOM SET XML ATTRIBUTE:C866($elemRef; "x"; $valObj.x+2) + // paddingLeft and paddingRight: invisible gaps of the "_x" and "?" of the number format + : ($valObj.hAlign=LEFT_HORIZONTAL_ALIGN) + DOM SET XML ATTRIBUTE:C866($elemRef; "x"; $valObj.x+2+Num:C11($valObj.paddingLeft)) + : ($valObj.hAlign=CENTER_HORIZONTAL_ALIGN) - DOM SET XML ATTRIBUTE:C866($elemRef; "x"; $valObj.x+($valObj.width/2); "text-anchor"; "middle") + DOM SET XML ATTRIBUTE:C866($elemRef; "x"; $valObj.x+($valObj.width/2)+((Num:C11($valObj.paddingLeft)-Num:C11($valObj.paddingRight))/2); "text-anchor"; "middle") : ($valObj.hAlign=RIGHT_HORIZONTAL_ALIGN) - DOM SET XML ATTRIBUTE:C866($elemRef; "x"; $valObj.x+$valObj.width-2; "text-anchor"; "end") + DOM SET XML ATTRIBUTE:C866($elemRef; "x"; $valObj.x+$valObj.width-2-Num:C11($valObj.paddingRight); "text-anchor"; "end") End case @@ -1433,11 +1437,11 @@ End for each DOM GET XML ATTRIBUTE BY NAME:C728($elemRef; "y"; $yPos) $part1Ref:=DOM Create XML element:C865($elemRef; "text"; "style"; $styleStr) $part2Ref:=DOM Create XML element:C865($elemRef; "text"; "style"; $styleStr) - DOM SET XML ATTRIBUTE:C866($part1Ref; "x"; $valObj.x+2; "y"; $yPos) + DOM SET XML ATTRIBUTE:C866($part1Ref; "x"; $valObj.x+2+Num:C11($valObj.paddingLeft); "y"; $yPos) If ($valObj.textOrientation#0) & ($valObj.textOrientation#NONE_TEXT_ORIENTATION) DOM SET XML ATTRIBUTE:C866($part2Ref; "x"; $valObj.x+svg_getTextWidth($valObj.strPart.part1)+2; "y"; $yPos) Else - DOM SET XML ATTRIBUTE:C866($part2Ref; "x"; $valObj.x+$valObj.width; "y"; $yPos; "text-anchor"; "end") + DOM SET XML ATTRIBUTE:C866($part2Ref; "x"; $valObj.x+$valObj.width-Num:C11($valObj.paddingRight); "y"; $yPos; "text-anchor"; "end") End if DOM SET XML ELEMENT VALUE:C868($part2Ref; $valObj.strPart.part2) DOM SET XML ELEMENT VALUE:C868($part1Ref; $valObj.strPart.part1) diff --git a/Project/Sources/Methods/vp_getCellRange.4dm b/Project/Sources/Methods/vp_getCellRange.4dm index 8944ce3..91b7086 100644 --- a/Project/Sources/Methods/vp_getCellRange.4dm +++ b/Project/Sources/Methods/vp_getCellRange.4dm @@ -1,5 +1,5 @@ //%attributes = {"invisible":true,"preemptive":"capable"} -C_OBJECT:C1216($1; $vpObject; $2; $cells; $sheets; $workbook; $table) +C_OBJECT:C1216($1; $vpObject; $2; $cells; $sheets; $workbook; $table; $sheetTable) C_LONGINT:C283($biggestCol; $biggestRow) C_OBJECT:C1216($range; $0) C_TEXT:C284($s; $currentCol; $currentRow) @@ -155,6 +155,22 @@ If ($cells=Null:C1517) End for each End if + // the empty cells of the tables are drawn too (banded rows, borders...) + If (Value type:C1509($sheets.tables)=Is collection:K8:32) + For each ($sheetTable; $sheets.tables) + If (Value type:C1509($sheetTable.row)=Is real:K8:4) & (Value type:C1509($sheetTable.rowCount)=Is real:K8:4) + If (($sheetTable.row+$sheetTable.rowCount-1)>$biggestCol) + $biggestCol:=$sheetTable.row+$sheetTable.rowCount-1 + End if + End if + If (Value type:C1509($sheetTable.col)=Is real:K8:4) & (Value type:C1509($sheetTable.colCount)=Is real:K8:4) + If (($sheetTable.col+$sheetTable.colCount-1)>$biggestRow) + $biggestRow:=$sheetTable.col+$sheetTable.colCount-1 + End if + End if + End for each + End if + $range.x1:=0 $range.x2:=$biggestRow $range.y1:=0 diff --git a/Resources/scripts/commands/file.js b/Resources/scripts/commands/file.js index b37a366..a468dcb 100644 --- a/Resources/scripts/commands/file.js +++ b/Resources/scripts/commands/file.js @@ -647,6 +647,7 @@ Utils.addFormatedText = function (json) { result.content = formatInfo.content; } else { result.text = formated; + _addPlaceholderInfo(result, formatInfo.content); } } else { result.text = formated; @@ -654,6 +655,57 @@ Utils.addFormatedText = function (json) { return result; } + function _isPlaceholder(element) { + return (element != null) && ((element.type === 'placeholder') || (element.type === 'numberPlaceholder')); + } + + function _getPlaceholderChar(element) { + if (element.type === 'numberPlaceholder') { + return '0'; + } + return (typeof (element.value) === 'string') ? element.value : ''; + } + + // "_x" and "?" of number formats are invisible gaps of the width of "x" (or of "0" for "?"), + // but the formatted text contains a single space for each of them: + // remove the leading and trailing ones from the text and give their chars to measure the gaps + function _addPlaceholderInfo(result, content) { + if ((!Array.isArray(content)) || (typeof (result.text) !== 'string')) { + return; + } + + let first = 0; + let placeholderLeft = ''; + while ((first < content.length) && _isPlaceholder(content[first])) { + placeholderLeft += _getPlaceholderChar(content[first]); + first++; + } + + let last = content.length - 1; + let placeholderRight = ''; + while ((last >= first) && _isPlaceholder(content[last])) { + placeholderRight = _getPlaceholderChar(content[last]) + placeholderRight; + last--; + } + + let text = result.text; + const leftCount = first; + const rightCount = content.length - 1 - last; + if ((leftCount > 0) && text.startsWith(' '.repeat(leftCount))) { + text = text.substring(leftCount); + if (placeholderLeft.length > 0) { + result.placeholderLeft = placeholderLeft; + } + } + if ((rightCount > 0) && (text.length >= rightCount) && text.endsWith(' '.repeat(rightCount))) { + text = text.substring(0, text.length - rightCount); + if (placeholderRight.length > 0) { + result.placeholderRight = placeholderRight; + } + } + result.text = text; + } + function _getWordWrapInfo() { let text; @@ -1039,6 +1091,165 @@ Utils.addFormatedText = function (json) { return converted; } + function _getBorderInfo(border) { + if (border == null) { + return null; + } + return { color: border.color, style: border.style }; + } + + function _getBackColorInfo(backColor) { + if (backColor == null) { + return null; + } + if (typeof (backColor) === 'string') { + return backColor; + } + // pattern fill: keep its background color (gradient fills are not handled) + if (typeof (backColor.backgroundColor) === 'string') { + return backColor.backgroundColor; + } + return undefined; + } + + function _getFormatString(formatter) { + if (typeof (formatter) === 'string') { + return formatter; + } + if ((formatter != null) && (typeof (formatter.formatString) === 'function')) { + return formatter.formatString(); + } + return null; + } + + // returns the visual properties that tables and conditional formats change in the style of the cell + function _getActualStyleInfo(liveSheet, r, c) { + const viewport = GC.Spread.Sheets.SheetArea.viewport; + const actualStyle = liveSheet.getActualStyle(r, c, viewport, false, { ignoreCellStatesStyle: true }); + // style without tables, pivot tables and conditional formats, as 4D computes it from the json + const baseStyle = liveSheet.getActualStyle(r, c, viewport, true, { isForFilter: true, doNotAddCache: true }); + const info = {}; + let changed = false; + + function setIfChanged(key, actualValue, baseValue) { + if ((actualValue !== undefined) && (JSON.stringify(actualValue) !== JSON.stringify(baseValue))) { + info[key] = actualValue; + changed = true; + } + } + + setIfChanged('backColor', _getBackColorInfo(actualStyle.backColor), _getBackColorInfo(baseStyle.backColor)); + ['foreColor', 'font', 'textDecoration', 'hAlign', 'vAlign'].forEach(key => { + setIfChanged(key, actualStyle[key], baseStyle[key]); + }); + ['borderLeft', 'borderTop', 'borderRight', 'borderBottom'].forEach(key => { + setIfChanged(key, _getBorderInfo(actualStyle[key]), _getBorderInfo(baseStyle[key])); + }); + + // number format set by a conditional format + const actualFormat = _getFormatString(actualStyle.formatter); + if ((actualFormat != null) && (actualFormat !== _getFormatString(baseStyle.formatter))) { + const dataTable = (sheet.data != null) ? sheet.data.dataTable : null; + if ((dataTable != null) && (dataTable[r] != null) && (dataTable[r][c] != null) && ('value' in dataTable[r][c])) { + cell = dataTable[r][c]; + row = r; + column = c; + info.formatInfo = _getFormatInfo(actualFormat); + changed = true; + } + } + + return changed ? info : null; + } + + // styles of tables and conditional formats are not in the cell styles of the json: + // store the style computed by SpreadJS for the cells they cover in a sheet key ignored by SpreadJS fromJSON, + // so that a reimported document doesn't get new cell styles + function _addActualStyleInfo(sheetName) { + const liveSheet = Utils.spread.getSheetFromName(sheetName); + if (liveSheet == null) { + return; + } + + const lastSheetRow = liveSheet.getRowCount() - 1; + const lastSheetColumn = liveSheet.getColumnCount() - 1; + const ranges = []; + let lastRow = -1; + let lastColumn = -1; + + function addRange(range, maxRow, maxColumn) { + const rowStart = Math.max(range.row, 0); + const columnStart = Math.max(range.col, 0); + const rowEnd = Math.min((range.row < 0) ? maxRow : range.row + range.rowCount - 1, maxRow); + const columnEnd = Math.min((range.col < 0) ? maxColumn : range.col + range.colCount - 1, maxColumn); + if ((rowStart <= rowEnd) && (columnStart <= columnEnd)) { + ranges.push({ rowStart, rowEnd, columnStart, columnEnd }); + } + } + + if (liveSheet.tables != null) { + liveSheet.tables.all().forEach(table => { + const range = table.range(); + addRange(range, lastSheetRow, lastSheetColumn); + lastRow = Math.max(lastRow, range.row + range.rowCount - 1); + lastColumn = Math.max(lastColumn, range.col + range.colCount - 1); + }); + } + + if (liveSheet.conditionalFormats != null) { + const rules = liveSheet.conditionalFormats.getRules(); + if ((rules != null) && (rules.length > 0)) { + // rules can cover whole rows or columns: stop at the last cell with a value or in a table + if ((sheet.data != null) && (sheet.data.dataTable != null)) { + const dataTable = sheet.data.dataTable; + for (const r in dataTable) { + for (const c in dataTable[r]) { + if ((dataTable[r][c] != null) && ('value' in dataTable[r][c])) { + lastRow = Math.max(lastRow, Number(r)); + lastColumn = Math.max(lastColumn, Number(c)); + } + } + } + } + const ruleType = GC.Spread.Sheets.ConditionalFormatting.RuleType; + rules.forEach(rule => { + // data bars and icon sets are painted in the cell, they don't change its style + if ((rule.ruleType() !== ruleType.dataBarRule) && (rule.ruleType() !== ruleType.iconSetRule)) { + (rule.ranges() || []).forEach(range => { + addRange(range, Math.min(lastRow, lastSheetRow), Math.min(lastColumn, lastSheetColumn)); + }); + } + }); + } + } + + const actualStyleInfo = {}; + const done = new Set(); + let found = false; + ranges.forEach(range => { + for (let r = range.rowStart; r <= range.rowEnd; r++) { + for (let c = range.columnStart; c <= range.columnEnd; c++) { + const key = r + ':' + c; + if (!done.has(key)) { + done.add(key); + const info = _getActualStyleInfo(liveSheet, r, c); + if (info != null) { + if (actualStyleInfo[r] == null) { + actualStyleInfo[r] = {}; + } + actualStyleInfo[r][c] = info; + found = true; + } + } + } + } + }); + + if (found) { + sheet.actualStyleInfo = actualStyleInfo; + } + } + for (const sheetName in json.sheets) { sheet = json.sheets[sheetName]; styleSheetPoolFormat = {}; @@ -1237,6 +1448,11 @@ Utils.addFormatedText = function (json) { } } } + try { + _addActualStyleInfo(sheetName); + } catch (e) { + // only used by VP Convert to picture: must never prevent the export + } sheetIndex++; } };