feat: release Office Tools 0.1.0
This commit is contained in:
@@ -0,0 +1,522 @@
|
||||
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 };
|
||||
}
|
||||
Reference in New Issue
Block a user