Files
office-tools/src/components/odf-viewer-model.ts
T

523 lines
16 KiB
TypeScript

import type { CSSProperties } from "react";
import type {
OdfDocument,
OdfLength,
OdfPresentationShape,
OdfSpreadsheetCell,
OdfSpreadsheetSheet,
OdfStyle,
OdfTextBlock,
OdfTextTable,
} from "../office/odf";
export const ODS_PAGE_ROWS = 200;
export const MAX_RENDERED_COLUMNS = 256;
export const MAX_TEXT_TABLE_ROWS = 500;
export const MAX_TEXT_TABLE_CELLS = 20_000;
const MAX_SEARCH_MATCHES = 10_000;
export interface OdfSearchMatch {
scopeIndex: number;
unitId: string;
rowIndex?: number;
}
export interface OdfSearchResult {
matches: OdfSearchMatch[];
truncated: boolean;
}
export interface ExpandedSheetCell {
cell: OdfSpreadsheetCell;
column: number;
repeated: boolean;
}
export interface ExpandedSheetRow {
rowIndex: number;
cells: ExpandedSheetCell[];
}
export interface TextTableViewRow {
rowIndex: number;
cells: Array<{
cell: OdfTextTable["rows"][number]["cells"][number];
columnIndex: number;
}>;
}
export interface TextTableView {
rows: TextTableViewRow[];
truncated: boolean;
}
export type StyleResolver = (
name: string | undefined,
family?: string,
) => CSSProperties;
function safeLength(value: string | undefined): string | undefined {
if (!value || value.length > 40) return undefined;
const trimmed = value.trim().toLowerCase();
if (trimmed === "auto") return "auto";
const match = /^(-?(?:\d+(?:\.\d+)?|\.\d+))(cm|mm|in|pt|pc|px|%)$/u.exec(
trimmed,
);
if (!match) return undefined;
const number = Number(match[1]);
const unit = match[2];
if (!Number.isFinite(number) || !unit) return undefined;
const bound = unit === "%" ? 1_000 : 10_000;
return `${Math.max(-bound, Math.min(bound, number))}${unit}`;
}
function safeColor(value: string | undefined): string | undefined {
if (!value || value.length > 80) return undefined;
const trimmed = value.trim().toLowerCase();
if (
trimmed === "transparent" ||
/^#[0-9a-f]{3,8}$/u.test(trimmed) ||
/^rgba?\(\s*[\d.%]+\s*,\s*[\d.%]+\s*,\s*[\d.%]+(?:\s*,\s*[\d.]+)?\s*\)$/u.test(
trimmed,
) ||
/^[a-z]{3,24}$/u.test(trimmed)
) {
return trimmed;
}
return undefined;
}
function safeBorder(value: string | undefined): string | undefined {
if (!value || value.length > 120 || /url|var|calc|[;{}]/iu.test(value))
return undefined;
const tokens = value.trim().split(/\s+/u);
if (tokens.length < 2 || tokens.length > 3) return undefined;
const width = tokens.find((token) => safeLength(token) !== undefined);
const style = tokens.find((token) =>
/^(?:none|solid|dashed|dotted|double)$/u.test(token),
);
const color = tokens.find((token) => safeColor(token) !== undefined);
if (!style) return undefined;
return [
width ? safeLength(width) : undefined,
style,
color ? safeColor(color) : undefined,
]
.filter(Boolean)
.join(" ");
}
function safeFontFamily(value: string | undefined): string | undefined {
if (!value || value.length > 160 || /[;{}]/u.test(value)) return undefined;
return /^[\p{L}\p{N}\s,'"._-]+$/u.test(value) ? value : undefined;
}
function safeOpacity(value: string | undefined): number | undefined {
if (!value) return undefined;
const percent = /^(\d+(?:\.\d+)?)%$/u.exec(value.trim());
const number = percent ? Number(percent[1]) / 100 : Number(value);
return Number.isFinite(number) ? Math.max(0, Math.min(1, number)) : undefined;
}
function propertiesToCss(
properties: Readonly<Record<string, string>>,
): CSSProperties {
const css: CSSProperties = {};
const valueFor = (suffix: string) => {
const entry = Object.entries(properties).find(([key]) =>
key.endsWith(suffix),
);
return entry?.[1];
};
css.color = safeColor(valueFor(".fo:color"));
css.backgroundColor =
safeColor(valueFor(".fo:background-color")) ??
safeColor(valueFor(".draw:fill-color"));
css.fontSize = safeLength(valueFor(".fo:font-size"));
css.fontFamily = safeFontFamily(valueFor(".fo:font-family"));
const fontWeight = valueFor(".fo:font-weight");
if (fontWeight && /^(?:normal|bold|[1-9]00)$/u.test(fontWeight))
css.fontWeight = fontWeight as CSSProperties["fontWeight"];
const fontStyle = valueFor(".fo:font-style");
if (fontStyle && /^(?:normal|italic|oblique)$/u.test(fontStyle))
css.fontStyle = fontStyle as CSSProperties["fontStyle"];
const textAlign = valueFor(".fo:text-align");
if (textAlign && /^(?:start|end|left|right|center|justify)$/u.test(textAlign))
css.textAlign = textAlign as CSSProperties["textAlign"];
const textTransform = valueFor(".fo:text-transform");
if (
textTransform &&
/^(?:none|capitalize|uppercase|lowercase)$/u.test(textTransform)
)
css.textTransform = textTransform as CSSProperties["textTransform"];
const underline = valueFor(".style:text-underline-style");
const strike = valueFor(".style:text-line-through-style");
if (underline && underline !== "none") css.textDecorationLine = "underline";
if (strike && strike !== "none")
css.textDecorationLine = css.textDecorationLine
? `${css.textDecorationLine} line-through`
: "line-through";
css.marginTop = safeLength(valueFor(".fo:margin-top"));
css.marginRight = safeLength(valueFor(".fo:margin-right"));
css.marginBottom = safeLength(valueFor(".fo:margin-bottom"));
css.marginLeft = safeLength(valueFor(".fo:margin-left"));
css.paddingTop = safeLength(valueFor(".fo:padding-top"));
css.paddingRight = safeLength(valueFor(".fo:padding-right"));
css.paddingBottom = safeLength(valueFor(".fo:padding-bottom"));
css.paddingLeft = safeLength(valueFor(".fo:padding-left"));
css.letterSpacing = safeLength(valueFor(".fo:letter-spacing"));
const lineHeight = valueFor(".fo:line-height");
if (lineHeight) {
const length = safeLength(lineHeight);
const scalar = Number(lineHeight);
if (length) css.lineHeight = length;
else if (Number.isFinite(scalar) && scalar >= 0.5 && scalar <= 10)
css.lineHeight = scalar;
}
css.border = safeBorder(valueFor(".fo:border"));
css.borderTop = safeBorder(valueFor(".fo:border-top"));
css.borderRight = safeBorder(valueFor(".fo:border-right"));
css.borderBottom = safeBorder(valueFor(".fo:border-bottom"));
css.borderLeft = safeBorder(valueFor(".fo:border-left"));
css.borderColor = safeColor(valueFor(".svg:stroke-color"));
css.borderWidth = safeLength(valueFor(".svg:stroke-width"));
css.opacity = safeOpacity(
valueFor(".draw:opacity") ?? valueFor(".svg:stroke-opacity"),
);
for (const key of Object.keys(css) as Array<keyof CSSProperties>) {
if (css[key] === undefined) delete css[key];
}
return css;
}
export function createStyleResolver(
styles: readonly OdfStyle[],
): StyleResolver {
const exact = new Map<string, OdfStyle>();
const byName = new Map<string, OdfStyle>();
for (const style of styles) {
exact.set(`${style.family ?? ""}\0${style.name}`, style);
if (!byName.has(style.name)) byName.set(style.name, style);
}
const cache = new Map<string, CSSProperties>();
return (name, family) => {
if (!name) return {};
const cacheKey = `${family ?? ""}\0${name}`;
const cached = cache.get(cacheKey);
if (cached) return cached;
const visited = new Set<string>();
const chain: OdfStyle[] = [];
let current = exact.get(cacheKey) ?? byName.get(name);
while (current && chain.length < 32 && !visited.has(current.name)) {
visited.add(current.name);
chain.unshift(current);
current = current.parentName
? (exact.get(
`${current.family ?? family ?? ""}\0${current.parentName}`,
) ?? byName.get(current.parentName))
: undefined;
}
const result = Object.assign(
{},
...chain.map((style) => propertiesToCss(style.properties)),
);
cache.set(cacheKey, result);
return result;
};
}
export function toCssLength(length: OdfLength | undefined): string | undefined {
if (!length || !Number.isFinite(length.value)) return undefined;
const bound = length.unit === "%" ? 1_000 : 10_000;
const value = Math.max(-bound, Math.min(bound, length.value));
return length.unit === "unitless" ? `${value}px` : `${value}${length.unit}`;
}
export function lengthToPixels(
length: OdfLength | undefined,
relativeTo: number,
): number | undefined {
if (!length || !Number.isFinite(length.value)) return undefined;
const factors: Record<
Exclude<OdfLength["unit"], "%" | "unitless">,
number
> = {
cm: 96 / 2.54,
mm: 96 / 25.4,
in: 96,
pt: 96 / 72,
pc: 16,
px: 1,
};
if (length.unit === "%") return (relativeTo * length.value) / 100;
if (length.unit === "unitless") return length.value;
return length.value * factors[length.unit];
}
export function textTableView(table: OdfTextTable): TextTableView {
const rows: TextTableViewRow[] = [];
let renderedCells = 0;
let expandedRowIndex = 0;
let truncated = false;
outer: for (const sourceRow of table.rows) {
for (let repeat = 0; repeat < sourceRow.rowRepeat; repeat += 1) {
if (rows.length >= MAX_TEXT_TABLE_ROWS) {
truncated = true;
break outer;
}
const cells: TextTableViewRow["cells"] = [];
let columnIndex = 0;
for (const sourceCell of sourceRow.cells) {
for (
let cellRepeat = 0;
cellRepeat < sourceCell.columnRepeat;
cellRepeat += 1
) {
if (
columnIndex >= MAX_RENDERED_COLUMNS ||
renderedCells >= MAX_TEXT_TABLE_CELLS
) {
truncated = true;
break;
}
cells.push({ cell: sourceCell, columnIndex });
columnIndex += 1;
renderedCells += 1;
}
if (truncated && renderedCells >= MAX_TEXT_TABLE_CELLS) break;
}
rows.push({ rowIndex: expandedRowIndex, cells });
expandedRowIndex += 1;
if (renderedCells >= MAX_TEXT_TABLE_CELLS) break outer;
}
}
return { rows, truncated };
}
export function sheetRowsForPage(
sheet: OdfSpreadsheetSheet,
page: number,
): ExpandedSheetRow[] {
const start = Math.max(0, page) * ODS_PAGE_ROWS;
const end = Math.min(sheet.expandedRowCount, start + ODS_PAGE_ROWS);
const result: ExpandedSheetRow[] = [];
if (start >= end) return result;
for (const sourceRow of sheet.rows) {
const sourceStart = sourceRow.index;
const sourceEnd = sourceStart + sourceRow.rowRepeat;
const visibleStart = Math.max(start, sourceStart);
const visibleEnd = Math.min(end, sourceEnd);
for (let rowIndex = visibleStart; rowIndex < visibleEnd; rowIndex += 1) {
const cells: ExpandedSheetCell[] = [];
for (const sourceCell of sourceRow.cells) {
for (
let repeat = 0;
repeat < sourceCell.columnRepeat &&
sourceCell.column + repeat < MAX_RENDERED_COLUMNS;
repeat += 1
) {
cells.push({
cell: sourceCell,
column: sourceCell.column + repeat,
repeated: repeat > 0 || rowIndex !== sourceStart,
});
}
}
result.push({ rowIndex, cells });
}
}
return result;
}
export function columnLabel(column: number): string {
let value = column + 1;
let result = "";
while (value > 0) {
value -= 1;
result = String.fromCharCode(65 + (value % 26)) + result;
value = Math.floor(value / 26);
}
return result;
}
export function flattenBlocks(blocks: readonly OdfTextBlock[]): string {
const parts: string[] = [];
const visit = (block: OdfTextBlock) => {
if (block.kind === "paragraph" || block.kind === "heading") {
parts.push(block.text);
for (const image of block.images)
parts.push(image.alt ?? image.title ?? "");
} else if (block.kind === "image") {
parts.push(block.alt ?? block.title ?? "");
} else if (block.kind === "list") {
for (const item of block.items)
for (const child of item.blocks) visit(child);
} else {
for (const row of block.rows)
for (const cell of row.cells)
for (const child of cell.blocks) visit(child);
}
};
for (const block of blocks) visit(block);
return parts.filter(Boolean).join("\n");
}
function textBlockMatches(
blocks: readonly OdfTextBlock[],
query: string,
scopeIndex: number,
prefix: string,
add: (text: string, match: OdfSearchMatch) => void,
): void {
const visit = (block: OdfTextBlock, path: string) => {
if (block.kind === "paragraph" || block.kind === "heading") {
add(block.text, { scopeIndex, unitId: path });
return;
}
if (block.kind === "image") {
add(block.alt ?? block.title ?? "", { scopeIndex, unitId: path });
return;
}
if (block.kind === "list") {
block.items.forEach((item, itemIndex) =>
item.blocks.forEach((child, childIndex) =>
visit(child, `${path}-i${itemIndex}-b${childIndex}`),
),
);
return;
}
const table = textTableView(block);
for (const row of table.rows) {
for (const { cell, columnIndex } of row.cells) {
if (cell.covered) continue;
const cellPath = `${path}-r${row.rowIndex}-c${columnIndex}`;
add(flattenBlocks(cell.blocks), { scopeIndex, unitId: cellPath });
}
}
};
blocks.forEach((block, index) => visit(block, `${prefix}-b${index}`));
void query;
}
function shapeTextMatches(
shape: OdfPresentationShape,
scopeIndex: number,
path: string,
add: (text: string, match: OdfSearchMatch) => void,
): void {
const parts = [
flattenBlocks(shape.blocks),
shape.table ? flattenBlocks([shape.table]) : "",
shape.image?.alt ?? shape.image?.title ?? "",
];
add(parts.filter(Boolean).join("\n"), { scopeIndex, unitId: path });
shape.children.forEach((child, index) =>
shapeTextMatches(child, scopeIndex, `${path}-s${index}`, add),
);
}
export function buildSearchResult(
document: OdfDocument,
rawQuery: string,
): OdfSearchResult {
const query = rawQuery.trim().toLocaleLowerCase();
if (!query) return { matches: [], truncated: false };
const matches: OdfSearchMatch[] = [];
let truncated = false;
const add = (text: string, match: OdfSearchMatch) => {
if (truncated || !text) return;
const haystack = text.toLocaleLowerCase();
let from = 0;
while (from <= haystack.length - query.length) {
const index = haystack.indexOf(query, from);
if (index < 0) break;
if (matches.length >= MAX_SEARCH_MATCHES) {
truncated = true;
return;
}
matches.push(match);
from = index + Math.max(1, query.length);
}
};
if (document.format === "odt") {
textBlockMatches(document.blocks, query, 0, "odt", add);
document.notes.forEach((note, index) =>
add(flattenBlocks(note.blocks), {
scopeIndex: 0,
unitId: `odt-note-${index}`,
}),
);
} else if (document.format === "ods") {
outer: for (
let sheetIndex = 0;
sheetIndex < document.sheets.length;
sheetIndex += 1
) {
const sheet = document.sheets[sheetIndex];
if (!sheet) continue;
for (const sourceRow of sheet.rows) {
if (sourceRow.cells.length === 0) continue;
for (
let rowRepeat = 0;
rowRepeat < sourceRow.rowRepeat;
rowRepeat += 1
) {
const rowIndex = sourceRow.index + rowRepeat;
for (const cell of sourceRow.cells) {
const text = [
...new Set([
cell.display,
cell.formula ?? "",
cell.value === undefined ? "" : String(cell.value),
cell.annotation ? flattenBlocks(cell.annotation) : "",
]),
]
.filter(Boolean)
.join("\n");
for (
let repeat = 0;
repeat < cell.columnRepeat &&
cell.column + repeat < MAX_RENDERED_COLUMNS;
repeat += 1
) {
const column = cell.column + repeat;
add(text, {
scopeIndex: sheetIndex,
rowIndex,
unitId: `ods-s${sheetIndex}-r${rowIndex}-c${column}`,
});
if (truncated) break outer;
}
}
}
}
}
} else {
document.slides.forEach((slide, slideIndex) => {
slide.shapes.forEach((shape, shapeIndex) =>
shapeTextMatches(
shape,
slideIndex,
`odp-s${slideIndex}-shape${shapeIndex}`,
add,
),
);
add(flattenBlocks(slide.notes), {
scopeIndex: slideIndex,
unitId: `odp-s${slideIndex}-notes`,
});
});
}
return { matches, truncated };
}