diff --git a/apps/server/src/share/content_renderer.ts b/apps/server/src/share/content_renderer.ts index e37ac8ab45..035f714b61 100644 --- a/apps/server/src/share/content_renderer.ts +++ b/apps/server/src/share/content_renderer.ts @@ -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 = `

${t("content_renderer.note-cannot-be-displayed")}

`; } @@ -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; diff --git a/packages/commons/src/index.ts b/packages/commons/src/index.ts index d140c3ee24..b208bfd3b6 100644 --- a/packages/commons/src/index.ts +++ b/packages/commons/src/index.ts @@ -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"; diff --git a/packages/commons/src/lib/spreadsheet/render_to_html.spec.ts b/packages/commons/src/lib/spreadsheet/render_to_html.spec.ts new file mode 100644 index 0000000000..ab83c25898 --- /dev/null +++ b/packages/commons/src/lib/spreadsheet/render_to_html.spec.ts @@ -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(""); + + // 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("

"); + }); + + 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("

Data

"); + expect(html).toContain("

Summary

"); + 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("

"); + }); + + 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: "" } } + }, + rowData: {}, + columnData: {} + } + } + } + }); + + const html = renderSpreadsheetToHtml(input); + expect(html).not.toContain("