mirror of
https://github.com/zadam/trilium.git
synced 2026-03-10 02:13:38 +01:00
feat(spreadsheet): basic rendering as HTML for share
This commit is contained in:
parent
d005c0ef2d
commit
83095130f6
@ -1,4 +1,5 @@
|
||||
import { sanitizeUrl } from "@braintree/sanitize-url";
|
||||
import { renderSpreadsheetToHtml } from "@triliumnext/commons";
|
||||
import { highlightAuto } from "@triliumnext/highlightjs";
|
||||
import ejs from "ejs";
|
||||
import escapeHtml from "escape-html";
|
||||
@ -286,6 +287,8 @@ export function getContent(note: SNote | BNote) {
|
||||
result.isEmpty = true;
|
||||
} else if (note.type === "webView") {
|
||||
renderWebView(note, result);
|
||||
} else if (note.type === "spreadsheet") {
|
||||
renderSpreadsheet(result);
|
||||
} else {
|
||||
result.content = `<p>${t("content_renderer.note-cannot-be-displayed")}</p>`;
|
||||
}
|
||||
@ -487,6 +490,14 @@ function renderFile(note: SNote | BNote, result: Result) {
|
||||
}
|
||||
}
|
||||
|
||||
function renderSpreadsheet(result: Result) {
|
||||
if (typeof result.content !== "string" || !result.content?.trim()) {
|
||||
result.isEmpty = true;
|
||||
} else {
|
||||
result.content = renderSpreadsheetToHtml(result.content);
|
||||
}
|
||||
}
|
||||
|
||||
function renderWebView(note: SNote | BNote, result: Result) {
|
||||
const url = note.getLabelValue("webViewSrc");
|
||||
if (!url) return;
|
||||
|
||||
@ -15,3 +15,4 @@ export * from "./lib/dayjs.js";
|
||||
export * from "./lib/notes.js";
|
||||
export * from "./lib/week_utils.js";
|
||||
export { default as BUILTIN_ATTRIBUTES } from "./lib/builtin_attributes.js";
|
||||
export * from "./lib/spreadsheet/render_to_html.js";
|
||||
|
||||
308
packages/commons/src/lib/spreadsheet/render_to_html.spec.ts
Normal file
308
packages/commons/src/lib/spreadsheet/render_to_html.spec.ts
Normal file
@ -0,0 +1,308 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { renderSpreadsheetToHtml } from "./render_to_html.js";
|
||||
|
||||
describe("renderSpreadsheetToHtml", () => {
|
||||
it("renders a basic spreadsheet with values and styles", () => {
|
||||
const input = JSON.stringify({
|
||||
version: 1,
|
||||
workbook: {
|
||||
id: "test",
|
||||
sheetOrder: ["sheet1"],
|
||||
name: "",
|
||||
appVersion: "0.16.1",
|
||||
locale: "zhCN",
|
||||
styles: {
|
||||
boldStyle: { bl: 1 }
|
||||
},
|
||||
sheets: {
|
||||
sheet1: {
|
||||
id: "sheet1",
|
||||
name: "Sheet1",
|
||||
hidden: 0,
|
||||
rowCount: 1000,
|
||||
columnCount: 20,
|
||||
defaultColumnWidth: 88,
|
||||
defaultRowHeight: 24,
|
||||
mergeData: [],
|
||||
cellData: {
|
||||
"1": {
|
||||
"1": { v: "lol", t: 1 }
|
||||
},
|
||||
"3": {
|
||||
"0": { v: "wut", t: 1 },
|
||||
"2": { s: "boldStyle", v: "Bold string", t: 1 }
|
||||
}
|
||||
},
|
||||
rowData: {},
|
||||
columnData: {},
|
||||
showGridlines: 1
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
const html = renderSpreadsheetToHtml(input);
|
||||
|
||||
// Should contain a table.
|
||||
expect(html).toContain("<table");
|
||||
expect(html).toContain("</table>");
|
||||
|
||||
// Should contain cell values.
|
||||
expect(html).toContain("lol");
|
||||
expect(html).toContain("wut");
|
||||
expect(html).toContain("Bold string");
|
||||
|
||||
// Bold cell should have font-weight:bold.
|
||||
expect(html).toContain("font-weight:bold");
|
||||
|
||||
// Should not render sheet header for single sheet.
|
||||
expect(html).not.toContain("<h3>");
|
||||
});
|
||||
|
||||
it("renders multiple visible sheets with headers", () => {
|
||||
const input = JSON.stringify({
|
||||
version: 1,
|
||||
workbook: {
|
||||
sheetOrder: ["s1", "s2"],
|
||||
styles: {},
|
||||
sheets: {
|
||||
s1: {
|
||||
id: "s1",
|
||||
name: "Data",
|
||||
hidden: 0,
|
||||
rowCount: 10,
|
||||
columnCount: 5,
|
||||
mergeData: [],
|
||||
cellData: { "0": { "0": { v: "A1" } } },
|
||||
rowData: {},
|
||||
columnData: {}
|
||||
},
|
||||
s2: {
|
||||
id: "s2",
|
||||
name: "Summary",
|
||||
hidden: 0,
|
||||
rowCount: 10,
|
||||
columnCount: 5,
|
||||
mergeData: [],
|
||||
cellData: { "0": { "0": { v: "B1" } } },
|
||||
rowData: {},
|
||||
columnData: {}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
const html = renderSpreadsheetToHtml(input);
|
||||
expect(html).toContain("<h3>Data</h3>");
|
||||
expect(html).toContain("<h3>Summary</h3>");
|
||||
expect(html).toContain("A1");
|
||||
expect(html).toContain("B1");
|
||||
});
|
||||
|
||||
it("skips hidden sheets", () => {
|
||||
const input = JSON.stringify({
|
||||
version: 1,
|
||||
workbook: {
|
||||
sheetOrder: ["s1", "s2"],
|
||||
styles: {},
|
||||
sheets: {
|
||||
s1: {
|
||||
id: "s1",
|
||||
name: "Visible",
|
||||
hidden: 0,
|
||||
rowCount: 10,
|
||||
columnCount: 5,
|
||||
mergeData: [],
|
||||
cellData: { "0": { "0": { v: "shown" } } },
|
||||
rowData: {},
|
||||
columnData: {}
|
||||
},
|
||||
s2: {
|
||||
id: "s2",
|
||||
name: "Hidden",
|
||||
hidden: 1,
|
||||
rowCount: 10,
|
||||
columnCount: 5,
|
||||
mergeData: [],
|
||||
cellData: { "0": { "0": { v: "secret" } } },
|
||||
rowData: {},
|
||||
columnData: {}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
const html = renderSpreadsheetToHtml(input);
|
||||
expect(html).toContain("shown");
|
||||
expect(html).not.toContain("secret");
|
||||
// Single visible sheet, no header.
|
||||
expect(html).not.toContain("<h3>");
|
||||
});
|
||||
|
||||
it("handles merged cells", () => {
|
||||
const input = JSON.stringify({
|
||||
version: 1,
|
||||
workbook: {
|
||||
sheetOrder: ["s1"],
|
||||
styles: {},
|
||||
sheets: {
|
||||
s1: {
|
||||
id: "s1",
|
||||
name: "Sheet1",
|
||||
hidden: 0,
|
||||
rowCount: 10,
|
||||
columnCount: 5,
|
||||
mergeData: [
|
||||
{ startRow: 0, endRow: 1, startColumn: 0, endColumn: 1 }
|
||||
],
|
||||
cellData: {
|
||||
"0": { "0": { v: "merged" } }
|
||||
},
|
||||
rowData: {},
|
||||
columnData: {}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
const html = renderSpreadsheetToHtml(input);
|
||||
expect(html).toContain('rowspan="2"');
|
||||
expect(html).toContain('colspan="2"');
|
||||
expect(html).toContain("merged");
|
||||
});
|
||||
|
||||
it("escapes HTML in cell values", () => {
|
||||
const input = JSON.stringify({
|
||||
version: 1,
|
||||
workbook: {
|
||||
sheetOrder: ["s1"],
|
||||
styles: {},
|
||||
sheets: {
|
||||
s1: {
|
||||
id: "s1",
|
||||
name: "Sheet1",
|
||||
hidden: 0,
|
||||
rowCount: 10,
|
||||
columnCount: 5,
|
||||
mergeData: [],
|
||||
cellData: {
|
||||
"0": { "0": { v: "<script>alert('xss')</script>" } }
|
||||
},
|
||||
rowData: {},
|
||||
columnData: {}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
const html = renderSpreadsheetToHtml(input);
|
||||
expect(html).not.toContain("<script>");
|
||||
expect(html).toContain("<script>");
|
||||
});
|
||||
|
||||
it("handles invalid JSON gracefully", () => {
|
||||
const html = renderSpreadsheetToHtml("not json");
|
||||
expect(html).toContain("Unable to parse");
|
||||
});
|
||||
|
||||
it("handles empty workbook", () => {
|
||||
const input = JSON.stringify({
|
||||
version: 1,
|
||||
workbook: {
|
||||
sheetOrder: ["s1"],
|
||||
styles: {},
|
||||
sheets: {
|
||||
s1: {
|
||||
id: "s1",
|
||||
name: "Sheet1",
|
||||
hidden: 0,
|
||||
rowCount: 10,
|
||||
columnCount: 5,
|
||||
mergeData: [],
|
||||
cellData: {},
|
||||
rowData: {},
|
||||
columnData: {}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
const html = renderSpreadsheetToHtml(input);
|
||||
expect(html).toContain("Empty sheet");
|
||||
});
|
||||
|
||||
it("renders boolean values", () => {
|
||||
const input = JSON.stringify({
|
||||
version: 1,
|
||||
workbook: {
|
||||
sheetOrder: ["s1"],
|
||||
styles: {},
|
||||
sheets: {
|
||||
s1: {
|
||||
id: "s1",
|
||||
name: "Sheet1",
|
||||
hidden: 0,
|
||||
rowCount: 10,
|
||||
columnCount: 5,
|
||||
mergeData: [],
|
||||
cellData: {
|
||||
"0": {
|
||||
"0": { v: true, t: 3 },
|
||||
"1": { v: false, t: 3 }
|
||||
}
|
||||
},
|
||||
rowData: {},
|
||||
columnData: {}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
const html = renderSpreadsheetToHtml(input);
|
||||
expect(html).toContain("TRUE");
|
||||
expect(html).toContain("FALSE");
|
||||
});
|
||||
|
||||
it("applies inline styles for colors, alignment, and borders", () => {
|
||||
const input = JSON.stringify({
|
||||
version: 1,
|
||||
workbook: {
|
||||
sheetOrder: ["s1"],
|
||||
styles: {},
|
||||
sheets: {
|
||||
s1: {
|
||||
id: "s1",
|
||||
name: "Sheet1",
|
||||
hidden: 0,
|
||||
rowCount: 10,
|
||||
columnCount: 5,
|
||||
mergeData: [],
|
||||
cellData: {
|
||||
"0": {
|
||||
"0": {
|
||||
v: "styled",
|
||||
s: {
|
||||
bg: { rgb: "#FF0000" },
|
||||
cl: { rgb: "#FFFFFF" },
|
||||
ht: 2,
|
||||
bd: {
|
||||
b: { s: 1, cl: { rgb: "#000000" } }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
rowData: {},
|
||||
columnData: {}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
const html = renderSpreadsheetToHtml(input);
|
||||
expect(html).toContain("background-color:#FF0000");
|
||||
expect(html).toContain("color:#FFFFFF");
|
||||
expect(html).toContain("text-align:center");
|
||||
expect(html).toContain("border-bottom:");
|
||||
});
|
||||
});
|
||||
420
packages/commons/src/lib/spreadsheet/render_to_html.ts
Normal file
420
packages/commons/src/lib/spreadsheet/render_to_html.ts
Normal file
@ -0,0 +1,420 @@
|
||||
/**
|
||||
* Converts a UniversJS workbook JSON structure into a static HTML table representation.
|
||||
* This is used for rendering spreadsheets in shared notes and exports.
|
||||
*
|
||||
* Only the subset of UniversJS types needed for rendering is defined here,
|
||||
* to avoid depending on @univerjs/core.
|
||||
*/
|
||||
|
||||
// #region UniversJS type subset
|
||||
|
||||
interface PersistedData {
|
||||
version: number;
|
||||
workbook: IWorkbookData;
|
||||
}
|
||||
|
||||
interface IWorkbookData {
|
||||
sheetOrder: string[];
|
||||
styles?: Record<string, IStyleData | null>;
|
||||
sheets: Record<string, IWorksheetData>;
|
||||
}
|
||||
|
||||
interface IWorksheetData {
|
||||
id: string;
|
||||
name: string;
|
||||
hidden?: number;
|
||||
rowCount: number;
|
||||
columnCount: number;
|
||||
defaultColumnWidth?: number;
|
||||
defaultRowHeight?: number;
|
||||
mergeData?: IRange[];
|
||||
cellData: CellMatrix;
|
||||
rowData?: Record<number, IRowData>;
|
||||
columnData?: Record<number, IColumnData>;
|
||||
showGridlines?: number;
|
||||
}
|
||||
|
||||
type CellMatrix = Record<number, Record<number, ICellData>>;
|
||||
|
||||
interface ICellData {
|
||||
v?: string | number | boolean | null;
|
||||
t?: number | null;
|
||||
s?: IStyleData | string | null;
|
||||
}
|
||||
|
||||
interface IStyleData {
|
||||
bl?: number;
|
||||
it?: number;
|
||||
ul?: ITextDecoration;
|
||||
st?: ITextDecoration;
|
||||
fs?: number;
|
||||
ff?: string | null;
|
||||
bg?: IColorStyle | null;
|
||||
cl?: IColorStyle | null;
|
||||
ht?: number | null;
|
||||
vt?: number | null;
|
||||
bd?: IBorderData | null;
|
||||
}
|
||||
|
||||
interface ITextDecoration {
|
||||
s?: number;
|
||||
}
|
||||
|
||||
interface IColorStyle {
|
||||
rgb?: string | null;
|
||||
}
|
||||
|
||||
interface IBorderData {
|
||||
t?: IBorderStyleData | null;
|
||||
r?: IBorderStyleData | null;
|
||||
b?: IBorderStyleData | null;
|
||||
l?: IBorderStyleData | null;
|
||||
}
|
||||
|
||||
interface IBorderStyleData {
|
||||
s?: number;
|
||||
cl?: IColorStyle;
|
||||
}
|
||||
|
||||
interface IRange {
|
||||
startRow: number;
|
||||
endRow: number;
|
||||
startColumn: number;
|
||||
endColumn: number;
|
||||
}
|
||||
|
||||
interface IRowData {
|
||||
h?: number;
|
||||
hd?: number;
|
||||
}
|
||||
|
||||
interface IColumnData {
|
||||
w?: number;
|
||||
hd?: number;
|
||||
}
|
||||
|
||||
// Alignment enums (from UniversJS)
|
||||
const enum HorizontalAlign {
|
||||
LEFT = 1,
|
||||
CENTER = 2,
|
||||
RIGHT = 3
|
||||
}
|
||||
|
||||
const enum VerticalAlign {
|
||||
TOP = 1,
|
||||
MIDDLE = 2,
|
||||
BOTTOM = 3
|
||||
}
|
||||
|
||||
// Border style enum
|
||||
const enum BorderStyle {
|
||||
THIN = 1,
|
||||
MEDIUM = 6,
|
||||
THICK = 9,
|
||||
DASHED = 3,
|
||||
DOTTED = 4
|
||||
}
|
||||
|
||||
// #endregion
|
||||
|
||||
/**
|
||||
* Parses the raw JSON content of a spreadsheet note and renders it as HTML.
|
||||
* Returns an HTML string containing one `<table>` per visible sheet.
|
||||
*/
|
||||
export function renderSpreadsheetToHtml(jsonContent: string): string {
|
||||
let data: PersistedData;
|
||||
try {
|
||||
data = JSON.parse(jsonContent);
|
||||
} catch {
|
||||
return "<p>Unable to parse spreadsheet data.</p>";
|
||||
}
|
||||
|
||||
if (!data?.workbook?.sheets) {
|
||||
return "<p>Empty spreadsheet.</p>";
|
||||
}
|
||||
|
||||
const { workbook } = data;
|
||||
const sheetIds = workbook.sheetOrder ?? Object.keys(workbook.sheets);
|
||||
const visibleSheets = sheetIds
|
||||
.map((id) => workbook.sheets[id])
|
||||
.filter((s) => s && !s.hidden);
|
||||
|
||||
if (visibleSheets.length === 0) {
|
||||
return "<p>Empty spreadsheet.</p>";
|
||||
}
|
||||
|
||||
const parts: string[] = [];
|
||||
for (const sheet of visibleSheets) {
|
||||
if (visibleSheets.length > 1) {
|
||||
parts.push(`<h3>${escapeHtml(sheet.name)}</h3>`);
|
||||
}
|
||||
parts.push(renderSheet(sheet, workbook.styles ?? {}));
|
||||
}
|
||||
|
||||
return parts.join("\n");
|
||||
}
|
||||
|
||||
function renderSheet(sheet: IWorksheetData, styles: Record<string, IStyleData | null>): string {
|
||||
const { cellData, mergeData = [], columnData = {}, rowData = {} } = sheet;
|
||||
|
||||
// Determine the actual bounds (only cells with data).
|
||||
const bounds = computeBounds(cellData, mergeData);
|
||||
if (!bounds) {
|
||||
return "<p>Empty sheet.</p>";
|
||||
}
|
||||
|
||||
const { minRow, maxRow, minCol, maxCol } = bounds;
|
||||
|
||||
// Build a set of cells that are hidden by merges (non-origin cells).
|
||||
const mergeMap = buildMergeMap(mergeData, minRow, maxRow, minCol, maxCol);
|
||||
|
||||
const lines: string[] = [];
|
||||
lines.push('<table class="spreadsheet-table">');
|
||||
|
||||
// Colgroup for column widths.
|
||||
const defaultWidth = sheet.defaultColumnWidth ?? 88;
|
||||
lines.push("<colgroup>");
|
||||
for (let col = minCol; col <= maxCol; col++) {
|
||||
const colMeta = columnData[col];
|
||||
if (colMeta?.hd) continue;
|
||||
const width = colMeta?.w ?? defaultWidth;
|
||||
lines.push(`<col style="width:${width}px">`);
|
||||
}
|
||||
lines.push("</colgroup>");
|
||||
|
||||
const defaultHeight = sheet.defaultRowHeight ?? 24;
|
||||
|
||||
for (let row = minRow; row <= maxRow; row++) {
|
||||
const rowMeta = rowData[row];
|
||||
if (rowMeta?.hd) continue;
|
||||
|
||||
const height = rowMeta?.h ?? defaultHeight;
|
||||
lines.push(`<tr style="height:${height}px">`);
|
||||
|
||||
for (let col = minCol; col <= maxCol; col++) {
|
||||
if (columnData[col]?.hd) continue;
|
||||
|
||||
const mergeInfo = mergeMap.get(cellKey(row, col));
|
||||
if (mergeInfo === "hidden") continue;
|
||||
|
||||
const cell = cellData[row]?.[col];
|
||||
const cellStyle = resolveCellStyle(cell, styles);
|
||||
const cssText = buildCssText(cellStyle);
|
||||
const value = formatCellValue(cell);
|
||||
|
||||
const attrs: string[] = [];
|
||||
if (cssText) attrs.push(`style="${cssText}"`);
|
||||
if (mergeInfo && mergeInfo !== "hidden") {
|
||||
if (mergeInfo.rowSpan > 1) attrs.push(`rowspan="${mergeInfo.rowSpan}"`);
|
||||
if (mergeInfo.colSpan > 1) attrs.push(`colspan="${mergeInfo.colSpan}"`);
|
||||
}
|
||||
|
||||
lines.push(`<td${attrs.length ? " " + attrs.join(" ") : ""}>${value}</td>`);
|
||||
}
|
||||
|
||||
lines.push("</tr>");
|
||||
}
|
||||
|
||||
lines.push("</table>");
|
||||
return lines.join("\n");
|
||||
}
|
||||
|
||||
// #region Bounds computation
|
||||
|
||||
interface Bounds {
|
||||
minRow: number;
|
||||
maxRow: number;
|
||||
minCol: number;
|
||||
maxCol: number;
|
||||
}
|
||||
|
||||
function computeBounds(cellData: CellMatrix, mergeData: IRange[]): Bounds | null {
|
||||
let minRow = Infinity;
|
||||
let maxRow = -Infinity;
|
||||
let minCol = Infinity;
|
||||
let maxCol = -Infinity;
|
||||
|
||||
for (const rowStr of Object.keys(cellData)) {
|
||||
const row = Number(rowStr);
|
||||
const cols = cellData[row];
|
||||
for (const colStr of Object.keys(cols)) {
|
||||
const col = Number(colStr);
|
||||
if (minRow > row) minRow = row;
|
||||
if (maxRow < row) maxRow = row;
|
||||
if (minCol > col) minCol = col;
|
||||
if (maxCol < col) maxCol = col;
|
||||
}
|
||||
}
|
||||
|
||||
// Extend bounds to cover merged ranges.
|
||||
for (const range of mergeData) {
|
||||
if (minRow > range.startRow) minRow = range.startRow;
|
||||
if (maxRow < range.endRow) maxRow = range.endRow;
|
||||
if (minCol > range.startColumn) minCol = range.startColumn;
|
||||
if (maxCol < range.endColumn) maxCol = range.endColumn;
|
||||
}
|
||||
|
||||
if (minRow > maxRow) return null;
|
||||
return { minRow, maxRow, minCol, maxCol };
|
||||
}
|
||||
|
||||
// #endregion
|
||||
|
||||
// #region Merge handling
|
||||
|
||||
interface MergeOrigin {
|
||||
rowSpan: number;
|
||||
colSpan: number;
|
||||
}
|
||||
|
||||
type MergeInfo = MergeOrigin | "hidden";
|
||||
|
||||
function cellKey(row: number, col: number): string {
|
||||
return `${row},${col}`;
|
||||
}
|
||||
|
||||
function buildMergeMap(mergeData: IRange[], minRow: number, maxRow: number, minCol: number, maxCol: number): Map<string, MergeInfo> {
|
||||
const map = new Map<string, MergeInfo>();
|
||||
|
||||
for (const range of mergeData) {
|
||||
const startRow = Math.max(range.startRow, minRow);
|
||||
const endRow = Math.min(range.endRow, maxRow);
|
||||
const startCol = Math.max(range.startColumn, minCol);
|
||||
const endCol = Math.min(range.endColumn, maxCol);
|
||||
|
||||
map.set(cellKey(range.startRow, range.startColumn), {
|
||||
rowSpan: endRow - startRow + 1,
|
||||
colSpan: endCol - startCol + 1
|
||||
});
|
||||
|
||||
for (let r = startRow; r <= endRow; r++) {
|
||||
for (let c = startCol; c <= endCol; c++) {
|
||||
if (r === range.startRow && c === range.startColumn) continue;
|
||||
map.set(cellKey(r, c), "hidden");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return map;
|
||||
}
|
||||
|
||||
// #endregion
|
||||
|
||||
// #region Style resolution
|
||||
|
||||
function resolveCellStyle(cell: ICellData | undefined, styles: Record<string, IStyleData | null>): IStyleData | null {
|
||||
if (!cell?.s) return null;
|
||||
|
||||
if (typeof cell.s === "string") {
|
||||
return styles[cell.s] ?? null;
|
||||
}
|
||||
|
||||
return cell.s;
|
||||
}
|
||||
|
||||
function buildCssText(style: IStyleData | null): string {
|
||||
if (!style) return "";
|
||||
|
||||
const parts: string[] = [];
|
||||
|
||||
if (style.bl) parts.push("font-weight:bold");
|
||||
if (style.it) parts.push("font-style:italic");
|
||||
if (style.ul?.s) parts.push("text-decoration:underline");
|
||||
if (style.st?.s) {
|
||||
// Combine with underline if both are set.
|
||||
const existing = parts.findIndex((p) => p.startsWith("text-decoration:"));
|
||||
if (existing >= 0) {
|
||||
parts[existing] = "text-decoration:underline line-through";
|
||||
} else {
|
||||
parts.push("text-decoration:line-through");
|
||||
}
|
||||
}
|
||||
if (style.fs) parts.push(`font-size:${style.fs}pt`);
|
||||
if (style.ff) parts.push(`font-family:${style.ff}`);
|
||||
if (style.bg?.rgb) parts.push(`background-color:${style.bg.rgb}`);
|
||||
if (style.cl?.rgb) parts.push(`color:${style.cl.rgb}`);
|
||||
|
||||
if (style.ht != null) {
|
||||
const align = horizontalAlignToCss(style.ht);
|
||||
if (align) parts.push(`text-align:${align}`);
|
||||
}
|
||||
if (style.vt != null) {
|
||||
const valign = verticalAlignToCss(style.vt);
|
||||
if (valign) parts.push(`vertical-align:${valign}`);
|
||||
}
|
||||
|
||||
if (style.bd) {
|
||||
appendBorderCss(parts, "border-top", style.bd.t);
|
||||
appendBorderCss(parts, "border-right", style.bd.r);
|
||||
appendBorderCss(parts, "border-bottom", style.bd.b);
|
||||
appendBorderCss(parts, "border-left", style.bd.l);
|
||||
}
|
||||
|
||||
return parts.join(";");
|
||||
}
|
||||
|
||||
function horizontalAlignToCss(align: number): string | null {
|
||||
switch (align) {
|
||||
case HorizontalAlign.LEFT: return "left";
|
||||
case HorizontalAlign.CENTER: return "center";
|
||||
case HorizontalAlign.RIGHT: return "right";
|
||||
default: return null;
|
||||
}
|
||||
}
|
||||
|
||||
function verticalAlignToCss(align: number): string | null {
|
||||
switch (align) {
|
||||
case VerticalAlign.TOP: return "top";
|
||||
case VerticalAlign.MIDDLE: return "middle";
|
||||
case VerticalAlign.BOTTOM: return "bottom";
|
||||
default: return null;
|
||||
}
|
||||
}
|
||||
|
||||
function appendBorderCss(parts: string[], property: string, border: IBorderStyleData | null | undefined): void {
|
||||
if (!border) return;
|
||||
const width = borderStyleToWidth(border.s);
|
||||
const color = border.cl?.rgb ?? "#000";
|
||||
const style = borderStyleToCss(border.s);
|
||||
parts.push(`${property}:${width} ${style} ${color}`);
|
||||
}
|
||||
|
||||
function borderStyleToWidth(style: number | undefined): string {
|
||||
switch (style) {
|
||||
case BorderStyle.MEDIUM: return "2px";
|
||||
case BorderStyle.THICK: return "3px";
|
||||
default: return "1px";
|
||||
}
|
||||
}
|
||||
|
||||
function borderStyleToCss(style: number | undefined): string {
|
||||
switch (style) {
|
||||
case BorderStyle.DASHED: return "dashed";
|
||||
case BorderStyle.DOTTED: return "dotted";
|
||||
default: return "solid";
|
||||
}
|
||||
}
|
||||
|
||||
// #endregion
|
||||
|
||||
// #region Value formatting
|
||||
|
||||
function formatCellValue(cell: ICellData | undefined): string {
|
||||
if (!cell || cell.v == null) return "";
|
||||
|
||||
if (typeof cell.v === "boolean") {
|
||||
return cell.v ? "TRUE" : "FALSE";
|
||||
}
|
||||
|
||||
return escapeHtml(String(cell.v));
|
||||
}
|
||||
|
||||
function escapeHtml(text: string): string {
|
||||
return text
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """);
|
||||
}
|
||||
|
||||
// #endregion
|
||||
Loading…
x
Reference in New Issue
Block a user