feat(spreadsheet): basic rendering as HTML for share

This commit is contained in:
Elian Doran 2026-03-08 20:04:14 +02:00
parent d005c0ef2d
commit 83095130f6
No known key found for this signature in database
4 changed files with 740 additions and 0 deletions

View File

@ -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;

View File

@ -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";

View 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("&lt;script&gt;");
});
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:");
});
});

View 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, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;");
}
// #endregion