523 lines
16 KiB
TypeScript
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 };
|
|
}
|