Merge branch 'main' into feat/integrations

# Conflicts:
#	apps/client/src/App.tsx
#	apps/server/src/ee
#	apps/server/src/integrations/queue/constants/queue.constants.ts
#	apps/server/src/integrations/queue/queue.module.ts
#	packages/editor-ext/src/index.ts
This commit is contained in:
Philipinho
2026-08-07 01:13:39 +01:00
406 changed files with 33557 additions and 5788 deletions
+2
View File
@@ -0,0 +1,2 @@
dist/
tsconfig.tsbuildinfo
+37
View File
@@ -0,0 +1,37 @@
The Docmost Enterprise License (the “Enterprise License”)
Copyright (c) 2023-present Docmost, Inc
With regard to the Docmost Software:
This software and associated documentation files (the "Software") may only be
used in production, if you (and any entity that you represent) have agreed to,
and are in compliance with, the Docmost Subscription Terms of Service, available
at https://docmost.com/terms (the “Enterprise Terms”), or other
agreement governing the use of the Software, as agreed by you and Docmost, Inc.,
and otherwise have a valid Docmost Enterprise Edition subscription for the correct number of user seats.
Subject to the foregoing sentence, you are free to
modify this Software and publish patches to the Software. You agree that Docmost
and/or its licensors (as applicable) retain all right, title and interest in and
to all such modifications and/or patches, and all such modifications and/or
patches may only be used, copied, modified, displayed, distributed, or otherwise
exploited with a valid Docmost Enterprise Edition subscription for the correct
number of user seats. Notwithstanding the foregoing, you may copy and modify
the Software for development and testing purposes, without requiring a
subscription. You agree that Docmost and/or its licensors (as applicable) retain
all right, title and interest in and to all such modifications. You are not
granted any other rights beyond what is expressly stated herein. Subject to the
foregoing, it is forbidden to copy, merge, publish, distribute, sublicense,
and/or sell the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
For all third party components incorporated into the Docmost Software, those
components are licensed under the original license provided by the owner of the
applicable component.
@@ -0,0 +1,329 @@
import {
parseRaw,
resolve,
typecheck,
evaluate,
registry,
DEFAULT_MAX_DEPTH,
} from "../src/index.server";
import type {
FormulaAST,
EvalContext,
PropertyLookup,
Value,
FormulaResultType,
} from "../src/index.server";
// sample row: properties a..j (numbers), name (string)
const NUM_PROPS = ["a", "b", "c", "d", "e", "f", "g", "h", "i", "j"];
const cells: Record<string, unknown> = {
prop_name: "widget",
};
NUM_PROPS.forEach((p, idx) => {
cells[`prop_${p}`] = (idx + 1) * 7.3 - idx; // arbitrary non-trivial floats
});
const nameToId = new Map<string, string>([
["name", "prop_name"],
...NUM_PROPS.map((p) => [p, `prop_${p}`] as [string, string]),
]);
const propertyTypes = new Map<string, FormulaResultType>([
["prop_name", "string"],
...NUM_PROPS.map(
(p) => [`prop_${p}`, "number"] as [string, FormulaResultType],
),
]);
// Base (non-formula) property lookup. Nested-formula cases extend this.
const baseProps = new Map<string, PropertyLookup>([
["prop_name", { id: "prop_name", type: "string", typeOptions: {} }],
...NUM_PROPS.map(
(p) =>
[`prop_${p}`, { id: `prop_${p}`, type: "number", typeOptions: {} }] as [
string,
PropertyLookup,
],
),
]);
function mkCtx(properties: ReadonlyMap<string, PropertyLookup>): EvalContext {
return {
registry,
properties,
depth: 0,
maxDepth: DEFAULT_MAX_DEPTH,
memo: new Map<string, Value>(),
};
}
//AST shape metrics
function astStats(ast: FormulaAST): { nodes: number; depth: number } {
let nodes = 0;
const walk = (n: FormulaAST, d: number): number => {
nodes++;
let max = d;
const kids: FormulaAST[] = [];
switch (n.t) {
case "op":
kids.push(...n.args);
break;
case "and":
case "or":
kids.push(...n.args);
break;
case "call":
kids.push(...n.args);
break;
case "if":
kids.push(n.cond, n.then, n.else);
break;
}
for (const k of kids) max = Math.max(max, walk(k, d + 1));
return max;
};
const depth = walk(ast, 1);
return { nodes, depth };
}
// timing harness
function timed(
fn: () => void,
targetMs = 600,
): { opsPerSec: number; nsPerOp: number } {
// warmup ~150ms to let V8 JIT settle
const warmEnd = performance.now() + 150;
while (performance.now() < warmEnd) fn();
// measure: 5 samples, keep the fastest (least noise from GC/scheduler)
let bestNsPerOp = Infinity;
for (let s = 0; s < 5; s++) {
// calibrate batch so each sample ~ targetMs
let iters = 1024;
let elapsedMs = 0;
while (true) {
const t0 = process.hrtime.bigint();
for (let i = 0; i < iters; i++) fn();
const t1 = process.hrtime.bigint();
elapsedMs = Number(t1 - t0) / 1e6;
if (elapsedMs >= targetMs) break;
iters = Math.ceil(
iters * Math.max(2, targetMs / Math.max(elapsedMs, 0.01)),
);
}
const nsPerOp = (elapsedMs * 1e6) / iters;
bestNsPerOp = Math.min(bestNsPerOp, nsPerOp);
}
return { opsPerSec: 1e9 / bestNsPerOp, nsPerOp: bestNsPerOp };
}
// formula corpus
type Case = { tier: string; name: string; src: string };
function buildArithChain(n: number): string {
// ((((a + b) * c) - d) ... ) cycling through props/ops
const ops = ["+", "*", "-"];
let expr = 'prop("a")';
for (let i = 0; i < n; i++) {
const p = NUM_PROPS[(i + 1) % NUM_PROPS.length];
const op = ops[i % ops.length];
expr = `(${expr} ${op} prop("${p}"))`;
}
return expr;
}
function buildIfChain(tiers: number): string {
// if(a>t1, "1", if(a>t2, "2", ... "fallback"))
let expr = '"fallback"';
for (let i = tiers; i >= 1; i--) {
expr = `if(prop("a") > ${i * 5}, "${i}", ${expr})`;
}
return expr;
}
function buildBalancedAddTree(depth: number): string {
// add(add(.., ..), add(.., ..)) = full binary tree of `add` calls
const leaf = () =>
`prop("${NUM_PROPS[Math.floor(Math.random() * NUM_PROPS.length)]}")`;
const build = (d: number): string =>
d === 0 ? leaf() : `add(${build(d - 1)}, ${build(d - 1)})`;
return build(depth);
}
const cases: Case[] = [
// BASIC
{ tier: "basic", name: "literal add", src: "1 + 2" },
{ tier: "basic", name: "two-prop add", src: 'prop("a") + prop("b")' },
{ tier: "basic", name: "comparison", src: 'prop("a") > 10' },
{ tier: "basic", name: "neg + mul", src: '-prop("a") * 2' },
// INTERMEDIATE
{
tier: "intermediate",
name: "round(mul)",
src: 'round(prop("a") * 1.5, 2)',
},
{
tier: "intermediate",
name: "if/then/else",
src: 'if(prop("a") > prop("b"), "hi", "lo")',
},
{
tier: "intermediate",
name: "string concat",
src: 'concat(upper(prop("name")), "-", toString(prop("a")))',
},
{
tier: "intermediate",
name: "bool and/or",
src: 'and(prop("a") > 0, or(prop("b") < 100, prop("c") == 0))',
},
// COMPLEX
{
tier: "complex",
name: "hypotenuse",
src: 'sqrt(pow(prop("a"), 2) + pow(prop("b"), 2))',
},
{
tier: "complex",
name: "sum(10 props)",
src: `sum(${NUM_PROPS.map((p) => `prop("${p}")`).join(", ")})`,
},
{
tier: "complex",
name: "nested if (4-tier grade)",
src: 'if(prop("a") > 90, "A", if(prop("a") > 80, "B", if(prop("a") > 70, "C", "F")))',
},
{
tier: "complex",
name: "mixed math+string+logic",
src: 'if(and(prop("a") > 0, prop("b") > 0), concat("ok:", toString(round(prop("a") / prop("b"), 2))), "n/a")',
},
// DEEPLY NESTED
{ tier: "deep", name: "arith chain x20", src: buildArithChain(20) },
{ tier: "deep", name: "nested if x10 tiers", src: buildIfChain(10) },
{ tier: "deep", name: "balanced fn tree d6", src: buildBalancedAddTree(6) },
];
// nested-formula (cross-property) case
// prop_total (formula) -> prop_sub (formula) -> raw props. Exercises evalProp
// recursion + per-row memoization, the multi-formula recompute hot path.
function buildNestedFormulaCtx(): {
ast: FormulaAST;
ctx: EvalContext;
stats: { nodes: number; depth: number };
} {
const subRaw = resolve(
parseRaw('round((prop("a") + prop("b") + prop("c")) / 3, 2)'),
nameToId,
).ast;
const totalRaw = resolve(
parseRaw('prop("sub") * prop("d") + prop("e")'),
// @ts-ignore
new Map([...nameToId, ["sub", "prop_sub"]]),
).ast;
const props = new Map<string, PropertyLookup>(baseProps);
props.set("prop_sub", {
id: "prop_sub",
type: "formula",
typeOptions: {
ast: subRaw,
source: "",
resultType: "number",
dependencies: [],
astVersion: 1,
},
});
return { ast: totalRaw, ctx: mkCtx(props), stats: astStats(totalRaw) };
}
// run
const fmt = (n: number) =>
n >= 1e6
? `${(n / 1e6).toFixed(2)}M`
: n >= 1e3
? `${(n / 1e3).toFixed(1)}K`
: n.toFixed(0);
console.log(`\nnode ${process.version} | base-formula engine benchmark\n`);
console.log(
"tier".padEnd(13) +
"formula".padEnd(28) +
"nodes".padStart(6) +
"depth".padStart(6) +
"compile op/s".padStart(15) +
"eval op/s".padStart(13) +
"eval ns/op".padStart(13),
);
console.log("-".repeat(94));
for (const c of cases) {
const raw = parseRaw(c.src);
const { ast } = resolve(raw, nameToId);
const stats = astStats(ast);
const ctx = mkCtx(baseProps);
const compile = timed(() => {
const r = resolve(parseRaw(c.src), nameToId);
typecheck(r.ast, propertyTypes, registry);
});
const ev = timed(() => {
ctx.memo.clear(); // fresh per "row" — matches production new Map() per row
evaluate(ast, cells, ctx);
});
console.log(
c.tier.padEnd(13) +
c.name.padEnd(28) +
String(stats.nodes).padStart(6) +
String(stats.depth).padStart(6) +
fmt(compile.opsPerSec).padStart(15) +
fmt(ev.opsPerSec).padStart(13) +
ev.nsPerOp.toFixed(0).padStart(13),
);
}
// nested cross-property formula
{
const { ast, ctx, stats } = buildNestedFormulaCtx();
const ev = timed(() => {
ctx.memo.clear();
evaluate(ast, cells, ctx);
});
console.log(
"nested-prop".padEnd(13) +
"total->sub->raw".padEnd(28) +
String(stats.nodes).padStart(6) +
String(stats.depth).padStart(6) +
"-".padStart(15) +
fmt(ev.opsPerSec).padStart(13) +
ev.nsPerOp.toFixed(0).padStart(13),
);
}
// whole-table simulation: eval N rows for the complex grade formula
console.log(
"\nwhole-table recompute simulation (mixed math+string+logic formula):",
);
const tableAst = resolve(
parseRaw(
'if(and(prop("a") > 0, prop("b") > 0), concat("ok:", toString(round(prop("a") / prop("b"), 2))), "n/a")',
),
nameToId,
).ast;
for (const rows of [1_000, 10_000, 100_000]) {
const ctx = mkCtx(baseProps);
const t0 = process.hrtime.bigint();
for (let r = 0; r < rows; r++) {
ctx.memo.clear();
evaluate(tableAst, cells, ctx);
}
const ms = Number(process.hrtime.bigint() - t0) / 1e6;
console.log(
` ${fmt(rows).padStart(6)} rows -> ${ms.toFixed(1)} ms (${fmt((rows / ms) * 1000)} rows/sec)`,
);
}
console.log();
+28
View File
@@ -0,0 +1,28 @@
{
"name": "@docmost/base-formula",
"homepage": "https://docmost.com",
"private": true,
"scripts": {
"build": "tsc --build",
"dev": "tsc --watch",
"bench": "tsx bench/formula-bench.ts"
},
"main": "dist/index.server.js",
"module": "./dist/index.server.js",
"exports": {
"./client": {
"types": "./dist/index.client.d.ts",
"default": "./src/index.client.ts"
},
"./server": {
"types": "./dist/index.server.d.ts",
"default": "./dist/index.server.js"
},
".": {
"types": "./dist/index.server.d.ts",
"default": "./dist/index.server.js"
}
},
"types": "dist/index.server.d.ts",
"dependencies": {}
}
+33
View File
@@ -0,0 +1,33 @@
export type OpCode =
| "+" | "-" | "*" | "/" | "%"
| "==" | "!=" | ">" | "<" | ">=" | "<="
| "neg" | "not";
export type FormulaAST =
| { t: "num"; v: number }
| { t: "str"; v: string }
| { t: "bool"; v: boolean }
| { t: "null" }
| { t: "prop"; id: string }
| { t: "op"; op: OpCode; args: FormulaAST[] }
| { t: "if"; cond: FormulaAST; then: FormulaAST; else: FormulaAST }
| { t: "and"; args: FormulaAST[] }
| { t: "or"; args: FormulaAST[] }
| { t: "call"; fn: string; args: FormulaAST[] };
/*
* Raw AST: what the parser produces before resolving property names to IDs.
* Only the `propName` variant differs from FormulaAST — every other node is
* reused directly. We deliberately keep this type-level to avoid duplicating
* the tree shape.
*/
export type RawFormulaAST =
| Exclude<FormulaAST, { t: "prop" }>
| { t: "propName"; name: string }
| { t: "op"; op: OpCode; args: RawFormulaAST[] }
| { t: "if"; cond: RawFormulaAST; then: RawFormulaAST; else: RawFormulaAST }
| { t: "and"; args: RawFormulaAST[] }
| { t: "or"; args: RawFormulaAST[] }
| { t: "call"; fn: string; args: RawFormulaAST[] };
export const AST_VERSION = 1 as const;
+41
View File
@@ -0,0 +1,41 @@
import type { ErrorCell, ErrorCode } from "./types";
export type ParseErrorCode =
| "UNEXPECTED_TOKEN"
| "UNEXPECTED_EOF"
| "UNKNOWN_PROPERTY"
| "UNKNOWN_FUNCTION"
| "ARITY_MISMATCH"
| "TYPE_MISMATCH"
| "CYCLE"
| "INPUT_TOO_LONG"
| "DEPTH_EXCEEDED";
export type ParseError = {
code: ParseErrorCode;
message: string;
span: { start: number; end: number };
hint?: string;
};
export class FormulaParseError extends Error {
readonly errors: ParseError[];
constructor(errors: ParseError[]) {
super(errors.map((e) => `${e.code}: ${e.message}`).join("; "));
this.errors = errors;
this.name = "FormulaParseError";
}
}
export function makeErrorCell(code: ErrorCode, msg: string): ErrorCell {
return { __err: code, msg, v: 1 };
}
export function isErrorCell(v: unknown): v is ErrorCell {
return (
typeof v === "object" &&
v !== null &&
"__err" in v &&
typeof (v as { __err: unknown }).__err === "string"
);
}
+126
View File
@@ -0,0 +1,126 @@
import { makeErrorCell, isErrorCell } from "./error";
import { valueToString } from "./number";
import { MAX_EVAL_DEPTH } from "./types";
import type { FormulaAST, OpCode } from "./ast";
import type { Value, EvalContext } from "./types";
export function evaluate(
ast: FormulaAST,
row: Record<string, unknown>,
ctx: EvalContext,
astDepth = 0,
): Value {
// astDepth bounds AST tree-walk recursion (guards a hand-crafted deep
// typeOptions.ast); ctx.depth separately bounds nested-formula hops.
const depth = astDepth + 1;
if (depth > MAX_EVAL_DEPTH) {
return makeErrorCell("DEPTH_EXCEEDED", `formula too deeply nested (max ${MAX_EVAL_DEPTH})`);
}
switch (ast.t) {
case "num": return ast.v;
case "str": return ast.v;
case "bool": return ast.v;
case "null": return null;
case "prop": return evalProp(ast.id, row, ctx, depth);
case "op": return evalOp(ast.op, ast.args, row, ctx, depth);
case "if": {
const c = evaluate(ast.cond, row, ctx, depth);
if (isErrorCell(c)) return c;
return evaluate(c === true ? ast.then : ast.else, row, ctx, depth);
}
case "and": {
const xs = ast.args;
for (let i = 0; i < xs.length; i++) {
const v = evaluate(xs[i], row, ctx, depth);
if (isErrorCell(v)) return v;
if (v === false) return false;
if (v == null) return null;
}
return true;
}
case "or": {
const xs = ast.args;
for (let i = 0; i < xs.length; i++) {
const v = evaluate(xs[i], row, ctx, depth);
if (isErrorCell(v)) return v;
if (v === true) return true;
}
return false;
}
case "call": {
const fn = ctx.registry.get(ast.fn.toLowerCase());
if (!fn) return makeErrorCell("MISSING_PROP", `unknown function ${ast.fn}`);
const xs = ast.args;
const args: Value[] = new Array(xs.length);
for (let i = 0; i < xs.length; i++) {
const v = evaluate(xs[i], row, ctx, depth);
if (isErrorCell(v)) return { ...v, __err: "DEPENDENCY_ERROR" };
args[i] = v;
}
try { return fn.eval(args, ctx); }
catch (e) { return makeErrorCell("TYPE_MISMATCH", (e as Error).message); }
}
}
}
function evalProp(id: string, row: Record<string, unknown>, ctx: EvalContext, astDepth: number): Value {
if (ctx.memo.has(id)) return ctx.memo.get(id)!;
const prop = ctx.properties.get(id);
if (!prop) return makeErrorCell("MISSING_PROP", `missing property ${id}`);
if (prop.type !== "formula") return normalize(row[id] ?? null);
// astDepth continues (not reset) across the nested-formula boundary.
if (ctx.depth >= ctx.maxDepth) return makeErrorCell("DEPTH_EXCEEDED", `max depth ${ctx.maxDepth}`);
const opts: any = prop.typeOptions;
const nested: EvalContext = { ...ctx, depth: ctx.depth + 1, memo: ctx.memo };
const v = evaluate(opts.ast, row, nested, astDepth);
ctx.memo.set(id, v);
return v;
}
function normalize(v: unknown): Value {
if (v === undefined) return null;
if (v === null) return null;
if (typeof v === "number" || typeof v === "string" || typeof v === "boolean") return v;
if (isErrorCell(v)) return v;
return null;
}
function evalOp(
op: OpCode,
args: FormulaAST[],
row: Record<string, unknown>,
ctx: EvalContext,
astDepth: number,
): Value {
const a = evaluate(args[0], row, ctx, astDepth);
if (isErrorCell(a)) return { ...a, __err: "DEPENDENCY_ERROR" };
if (op === "neg") return a == null ? null : -Number(a);
if (op === "not") return a == null ? null : !Boolean(a);
const b = evaluate(args[1], row, ctx, astDepth);
if (isErrorCell(b)) return { ...b, __err: "DEPENDENCY_ERROR" };
switch (op as Exclude<OpCode, "neg" | "not">) {
case "+":
if (typeof a === "string" || typeof b === "string") return valueToString(a) + valueToString(b);
if (a == null || b == null) return null;
return Number(a) + Number(b);
case "-": return a == null || b == null ? null : Number(a) - Number(b);
case "*": return a == null || b == null ? null : Number(a) * Number(b);
case "/":
if (a == null || b == null) return null;
if (Number(b) === 0) return makeErrorCell("DIV_BY_ZERO", "division by zero");
return Number(a) / Number(b);
case "%":
if (a == null || b == null) return null;
if (Number(b) === 0) return makeErrorCell("DIV_BY_ZERO", "modulo by zero");
return Number(a) % Number(b);
case "==": return a === b;
case "!=": return a !== b;
case ">": return a != null && b != null && (a as any) > (b as any);
case "<": return a != null && b != null && (a as any) < (b as any);
case ">=": return a != null && b != null && (a as any) >= (b as any);
case "<=": return a != null && b != null && (a as any) <= (b as any);
}
}
+31
View File
@@ -0,0 +1,31 @@
import type { FormulaAST, OpCode } from "./ast";
const OP_STR: Partial<Record<OpCode, string>> = {
"+": " + ", "-": " - ", "*": " * ", "/": " / ", "%": " % ",
"==": " == ", "!=": " != ", ">": " > ", "<": " < ", ">=": " >= ", "<=": " <= ",
};
export function format(
ast: FormulaAST,
idToName: ReadonlyMap<string, string>,
): string {
switch (ast.t) {
case "num": return String(ast.v);
case "str": return `"${ast.v.replace(/\\/g, "\\\\").replace(/"/g, '\\"')}"`;
case "bool": return ast.v ? "true" : "false";
case "null": return "null";
case "prop": return `prop("${idToName.get(ast.id) ?? ast.id}")`;
case "op":
if (ast.op === "neg") return `-${format(ast.args[0], idToName)}`;
if (ast.op === "not") return `not ${format(ast.args[0], idToName)}`;
return `(${format(ast.args[0], idToName)}${OP_STR[ast.op]}${format(ast.args[1], idToName)})`;
case "if":
return `if(${format(ast.cond, idToName)}, ${format(ast.then, idToName)}, ${format(ast.else, idToName)})`;
case "and":
return `(${ast.args.map((a) => format(a, idToName)).join(" and ")})`;
case "or":
return `(${ast.args.map((a) => format(a, idToName)).join(" or ")})`;
case "call":
return `${ast.fn}(${ast.args.map((a) => format(a, idToName)).join(", ")})`;
}
}
@@ -0,0 +1,17 @@
import { register } from "./registry";
import { valueToString } from "../number";
register({
name: "toNumber", arity: { min: 1, max: 1 }, paramTypes: "any", returnType: "number",
eval: ([v]) => {
if (v == null) return null;
const n = Number(v);
return Number.isFinite(n) ? n : null;
},
doc: "Parses the value as a number, or null.", category: "coercion",
});
register({
name: "toString", arity: { min: 1, max: 1 }, paramTypes: "any", returnType: "string",
eval: ([v]) => valueToString(v),
doc: "Converts the value to a string.", category: "coercion",
});
@@ -0,0 +1,53 @@
import { register } from "./registry";
import { makeErrorCell } from "../error";
const toDate = (v: unknown): Date | null => {
if (v == null) return null;
const d = new Date(String(v));
return isNaN(d.getTime()) ? null : d;
};
register({
name: "now", arity: { min: 0, max: 0 }, paramTypes: [], returnType: "date",
eval: () => new Date().toISOString(),
doc: "Current timestamp.", category: "date",
});
register({
name: "today", arity: { min: 0, max: 0 }, paramTypes: [], returnType: "date",
eval: () => {
const d = new Date(); d.setUTCHours(0, 0, 0, 0); return d.toISOString();
},
doc: "Midnight UTC of today.", category: "date",
});
register({
name: "dateAdd", arity: { min: 3, max: 3 }, paramTypes: ["date", "number", "string"], returnType: "date",
eval: ([base, amt, unit]) => {
const d = toDate(base);
if (!d) return makeErrorCell("DATE_INVALID", "invalid date");
const n = Number(amt);
const u = String(unit);
const r = new Date(d);
if (u === "days") r.setUTCDate(r.getUTCDate() + n);
else if (u === "hours") r.setUTCHours(r.getUTCHours() + n);
else if (u === "minutes") r.setUTCMinutes(r.getUTCMinutes() + n);
else if (u === "months") r.setUTCMonth(r.getUTCMonth() + n);
else if (u === "years") r.setUTCFullYear(r.getUTCFullYear() + n);
else return makeErrorCell("TYPE_MISMATCH", `unknown unit ${u}`);
return r.toISOString();
},
doc: "Adds a duration to a date. Units: days, hours, minutes, months, years.", category: "date",
});
register({
name: "dateBetween", arity: { min: 3, max: 3 }, paramTypes: ["date", "date", "string"], returnType: "number",
eval: ([a, b, unit]) => {
const da = toDate(a), db = toDate(b);
if (!da || !db) return makeErrorCell("DATE_INVALID", "invalid date");
const ms = db.getTime() - da.getTime();
const u = String(unit);
if (u === "days") return Math.floor(ms / 86_400_000);
if (u === "hours") return Math.floor(ms / 3_600_000);
if (u === "minutes") return Math.floor(ms / 60_000);
return makeErrorCell("TYPE_MISMATCH", `unknown unit ${u}`);
},
doc: "Difference between two dates in a given unit.", category: "date",
});
@@ -0,0 +1,7 @@
import "./logic";
import "./math";
import "./string";
import "./date";
import "./coercion";
export { registry, register } from "./registry";
export type { FormulaFn } from "./registry";
@@ -0,0 +1,11 @@
import { register } from "./registry";
register({
name: "empty",
arity: { min: 1, max: 1 },
paramTypes: "any",
returnType: "boolean",
eval: ([v]) => v == null || v === "" || (typeof v === "object" && v !== null && "__err" in v),
doc: "Returns true if the value is null or empty string or an error.",
category: "logic",
});
+160
View File
@@ -0,0 +1,160 @@
import { register } from "./registry";
import { makeErrorCell } from "../error";
import type { Value } from "../types";
const num = (v: unknown): number | null => v == null ? null : Number(v);
register({
name: "round", arity: { min: 1, max: 2 }, paramTypes: ["number", "number"], returnType: "number",
eval: ([v, places]) => {
const n = num(v);
if (n == null) return null;
const p = places == null ? 0 : Math.trunc(Number(places));
const factor = Math.pow(10, p);
return Math.round(n * factor) / factor;
},
doc: "Rounds to the nearest integer, or to `places` decimals if given.", category: "math",
});
register({
name: "floor", arity: { min: 1, max: 1 }, paramTypes: ["number"], returnType: "number",
eval: ([v]) => { const n = num(v); return n == null ? null : Math.floor(n); },
doc: "Rounds down.", category: "math",
});
register({
name: "ceil", arity: { min: 1, max: 1 }, paramTypes: ["number"], returnType: "number",
eval: ([v]) => { const n = num(v); return n == null ? null : Math.ceil(n); },
doc: "Rounds up.", category: "math",
});
register({
name: "abs", arity: { min: 1, max: 1 }, paramTypes: ["number"], returnType: "number",
eval: ([v]) => { const n = num(v); return n == null ? null : Math.abs(n); },
doc: "Absolute value.", category: "math",
});
register({
name: "min", arity: { min: 1, max: null }, paramTypes: "variadic-any", returnType: "number",
eval: (args) => {
const nums = args.map(num).filter((n): n is number => n != null);
return nums.length ? Math.min(...nums) : null;
},
doc: "Minimum of the arguments.", category: "math",
});
register({
name: "max", arity: { min: 1, max: null }, paramTypes: "variadic-any", returnType: "number",
eval: (args) => {
const nums = args.map(num).filter((n): n is number => n != null);
return nums.length ? Math.max(...nums) : null;
},
doc: "Maximum of the arguments.", category: "math",
});
register({
name: "mod", arity: { min: 2, max: 2 }, paramTypes: ["number", "number"], returnType: "number",
eval: ([a, b]) => {
const na = num(a), nb = num(b);
if (na == null || nb == null) return null;
if (nb === 0) return makeErrorCell("DIV_BY_ZERO", "modulo by zero");
return na % nb;
},
doc: "Remainder after division.", category: "math",
});
register({
name: "add", arity: { min: 2, max: 2 }, paramTypes: ["number", "number"], returnType: "number",
eval: ([a, b]) => {
const na = num(a), nb = num(b);
return na == null || nb == null ? null : na + nb;
},
doc: "Sum of two numbers.", category: "math",
});
register({
name: "subtract", arity: { min: 2, max: 2 }, paramTypes: ["number", "number"], returnType: "number",
eval: ([a, b]) => {
const na = num(a), nb = num(b);
return na == null || nb == null ? null : na - nb;
},
doc: "Difference of two numbers.", category: "math",
});
register({
name: "multiply", arity: { min: 2, max: 2 }, paramTypes: ["number", "number"], returnType: "number",
eval: ([a, b]) => {
const na = num(a), nb = num(b);
return na == null || nb == null ? null : na * nb;
},
doc: "Product of two numbers.", category: "math",
});
register({
name: "divide", arity: { min: 2, max: 2 }, paramTypes: ["number", "number"], returnType: "number",
eval: ([a, b]) => {
const na = num(a), nb = num(b);
if (na == null || nb == null) return null;
if (nb === 0) return makeErrorCell("DIV_BY_ZERO", "division by zero");
return na / nb;
},
doc: "Quotient of two numbers.", category: "math",
});
register({
name: "pow", arity: { min: 2, max: 2 }, paramTypes: ["number", "number"], returnType: "number",
eval: ([a, b]) => {
const na = num(a), nb = num(b);
return na == null || nb == null ? null : Math.pow(na, nb);
},
doc: "Base raised to an exponent.", category: "math",
});
register({
name: "sqrt", arity: { min: 1, max: 1 }, paramTypes: ["number"], returnType: "number",
eval: ([v]) => {
const n = num(v);
if (n == null) return null;
if (n < 0) return makeErrorCell("TYPE_MISMATCH", "sqrt of negative number");
return Math.sqrt(n);
},
doc: "Positive square root.", category: "math",
});
register({
name: "sum", arity: { min: 1, max: null }, paramTypes: "variadic-any", returnType: "number",
eval: (args) => {
// Null propagates as 0 so `sum(prop("A"), prop("B"))` still works when
// some cells are empty — matches Airtable/Notion semantics.
let total = 0;
for (const v of args) {
const n = num(v);
if (n != null && Number.isFinite(n)) total += n;
}
return total;
},
doc: "Sum of the arguments.", category: "math",
});
const meanEval = (args: Value[]): Value => {
const nums: number[] = [];
for (const v of args) {
const n = num(v);
if (n != null && Number.isFinite(n)) nums.push(n);
}
if (nums.length === 0) return null;
return nums.reduce((a, b) => a + b, 0) / nums.length;
};
register({
name: "mean", arity: { min: 1, max: null }, paramTypes: "variadic-any", returnType: "number",
eval: meanEval,
doc: "Arithmetic average of the arguments.", category: "math",
});
register({
name: "average", arity: { min: 1, max: null }, paramTypes: "variadic-any", returnType: "number",
eval: meanEval,
doc: "Arithmetic average of the arguments (alias of mean).", category: "math",
});
register({
name: "median", arity: { min: 1, max: null }, paramTypes: "variadic-any", returnType: "number",
eval: (args) => {
const nums: number[] = [];
for (const v of args) {
const n = num(v);
if (n != null && Number.isFinite(n)) nums.push(n);
}
if (nums.length === 0) return null;
nums.sort((a, b) => a - b);
const mid = Math.floor(nums.length / 2);
return nums.length % 2 === 0
? (nums[mid - 1] + nums[mid]) / 2
: nums[mid];
},
doc: "Middle value of the arguments.", category: "math",
});
@@ -0,0 +1,24 @@
import type { FormulaResultType, Value, EvalContext } from "../types";
export type FormulaFn = {
name: string;
arity: { min: number; max: number | null };
paramTypes: FormulaResultType[] | "any" | "variadic-any";
returnType: FormulaResultType | ((argTypes: FormulaResultType[]) => FormulaResultType);
eval: (args: Value[], ctx: EvalContext) => Value;
doc: string;
category: "logic" | "math" | "string" | "date" | "coercion";
};
export const registry: Map<string, FormulaFn> = new Map();
export function register(fn: FormulaFn): void {
// Functions are looked up case-insensitively (see eval/typecheck), so the
// registry is keyed by the lowercased name. fn.name keeps its canonical
// casing for display in the function picker and `format()`.
const key = fn.name.toLowerCase();
if (registry.has(key)) {
throw new Error(`Duplicate formula function: ${fn.name}`);
}
registry.set(key, fn);
}
@@ -0,0 +1,35 @@
import { register } from "./registry";
import { valueToString } from "../number";
const s = (v: unknown): string => valueToString(v);
register({
name: "concat", arity: { min: 1, max: null }, paramTypes: "variadic-any", returnType: "string",
eval: (args) => args.map(s).join(""),
doc: "Concatenates strings.", category: "string",
});
register({
name: "length", arity: { min: 1, max: 1 }, paramTypes: ["string"], returnType: "number",
eval: ([v]) => s(v).length,
doc: "Length of a string.", category: "string",
});
register({
name: "contains", arity: { min: 2, max: 2 }, paramTypes: ["string", "string"], returnType: "boolean",
eval: ([a, b]) => s(a).includes(s(b)),
doc: "Returns true if the first string contains the second.", category: "string",
});
register({
name: "lower", arity: { min: 1, max: 1 }, paramTypes: ["string"], returnType: "string",
eval: ([v]) => s(v).toLowerCase(),
doc: "Lowercases the string.", category: "string",
});
register({
name: "upper", arity: { min: 1, max: 1 }, paramTypes: ["string"], returnType: "string",
eval: ([v]) => s(v).toUpperCase(),
doc: "Uppercases the string.", category: "string",
});
register({
name: "trim", arity: { min: 1, max: 1 }, paramTypes: ["string"], returnType: "string",
eval: ([v]) => s(v).trim(),
doc: "Strips whitespace from both ends.", category: "string",
});
+88
View File
@@ -0,0 +1,88 @@
type PropLike = { id: string; type: string; typeOptions: unknown };
export class BaseFormulaGraph {
private readonly direct = new Map<string, string[]>();
private readonly reverse = new Map<string, Set<string>>();
constructor(properties: PropLike[]) {
for (const p of properties) {
if (p.type !== "formula") continue;
const deps: string[] = Array.isArray((p.typeOptions as any)?.dependencies)
? ((p.typeOptions as any).dependencies as string[])
: [];
this.direct.set(p.id, deps);
for (const d of deps) {
if (!this.reverse.has(d)) this.reverse.set(d, new Set());
this.reverse.get(d)!.add(p.id);
}
}
}
directDeps(propId: string): string[] { return this.direct.get(propId) ?? []; }
dependents(propId: string): string[] { return Array.from(this.reverse.get(propId) ?? []); }
affectedFormulas(changedPropIds: string[]): string[] {
const out = new Set<string>();
const stack = [...changedPropIds];
while (stack.length) {
const id = stack.pop()!;
for (const d of this.reverse.get(id) ?? []) {
if (!out.has(d)) { out.add(d); stack.push(d); }
}
}
return Array.from(out).sort();
}
evalOrder(): string[] {
const order: string[] = [];
const visited = new Set<string>();
const temp = new Set<string>();
const visit = (id: string) => {
if (visited.has(id)) return;
if (temp.has(id)) return;
temp.add(id);
for (const d of this.direct.get(id) ?? []) visit(d);
temp.delete(id);
visited.add(id);
order.push(id);
};
for (const id of this.direct.keys()) visit(id);
return order;
}
/*
* Returns the cycle path (list of prop IDs) if introducing `newProp` (or
* keeping its current deps) would create one, else null. `newProp` may be
* either a property already registered or a hypothetical replacement; we
* re-read its deps at call time, so pass the candidate object.
*/
detectCycle(newProp: PropLike): string[] | null {
const local = new Map(this.direct);
if (newProp.type === "formula") {
local.set(newProp.id, (newProp.typeOptions as any)?.dependencies ?? []);
}
const WHITE = 0, GRAY = 1, BLACK = 2;
const color = new Map<string, number>();
const path: string[] = [];
const dfs = (id: string): string[] | null => {
color.set(id, GRAY);
path.push(id);
for (const d of local.get(id) ?? []) {
const c = color.get(d) ?? WHITE;
if (c === GRAY) { return [...path.slice(path.indexOf(d)), d]; }
if (c === WHITE) { const r = dfs(d); if (r) return r; }
}
path.pop();
color.set(id, BLACK);
return null;
};
for (const id of local.keys()) {
if ((color.get(id) ?? WHITE) === WHITE) {
const r = dfs(id);
if (r) return r;
}
}
return null;
}
}
+14
View File
@@ -0,0 +1,14 @@
// Client-side public surface: parse, typecheck, cycle-detect, pretty-print.
import "./functions/index";
export * from "./ast";
export * from "./types";
export * from "./error";
export * from "./tokenizer";
export * from "./parser";
export * from "./resolver";
export * from "./typecheck";
export * from "./format";
export { registry, register } from "./functions/registry";
export type { FormulaFn } from "./functions/registry";
export * from "./graph";
export * from "./number";
+15
View File
@@ -0,0 +1,15 @@
// Server-side public surface: everything in client + evaluator + registry.
export * from "./ast";
export * from "./types";
export * from "./error";
export * from "./tokenizer";
export * from "./parser";
export * from "./resolver";
export * from "./typecheck";
export * from "./format";
import "./functions/index"; // side-effect: populate registry
export { registry, register } from "./functions/index";
export type { FormulaFn } from "./functions/index";
export * from "./graph";
export * from "./eval";
export * from "./number";
+10
View File
@@ -0,0 +1,10 @@
export function snapNumber(n: number): number {
if (!Number.isFinite(n)) return n;
return Number(n.toPrecision(15));
}
export function valueToString(v: unknown): string {
if (v == null) return "";
if (typeof v === "number") return String(snapNumber(v));
return String(v);
}
+201
View File
@@ -0,0 +1,201 @@
import { tokenize, Token, TokenKind } from "./tokenizer";
import { FormulaParseError } from "./error";
import { MAX_PARSE_DEPTH } from "./types";
import type { OpCode } from "./ast";
import type { RawFormulaAST } from "./ast";
/*
* Pratt parser. Top-level entry parses a full expression and then asserts EOF.
* Binary operators are dispatched through a precedence table in `bp` below.
* `prop(...)`, `if(...)`, `and(...)`, `or(...)` are intercepted when an
* identifier is followed by `(` so they become their dedicated AST nodes.
*/
export function parseRaw(src: string): RawFormulaAST {
const tokens = tokenize(src);
const p = new Parser(tokens);
const expr = p.parseExpr(0);
p.expect(TokenKind.EOF, "Expected end of input");
return expr;
}
const BP: Partial<Record<TokenKind, number>> = {
[TokenKind.OR]: 10,
[TokenKind.AND]: 20,
[TokenKind.EQ]: 30, [TokenKind.NEQ]: 30,
[TokenKind.LT]: 40, [TokenKind.GT]: 40,
[TokenKind.LTE]: 40, [TokenKind.GTE]: 40,
[TokenKind.PLUS]: 50, [TokenKind.MINUS]: 50,
[TokenKind.STAR]: 60, [TokenKind.SLASH]: 60, [TokenKind.PERCENT]: 60,
};
const TOK_TO_OP: Partial<Record<TokenKind, OpCode>> = {
[TokenKind.PLUS]: "+", [TokenKind.MINUS]: "-",
[TokenKind.STAR]: "*", [TokenKind.SLASH]: "/", [TokenKind.PERCENT]: "%",
[TokenKind.EQ]: "==", [TokenKind.NEQ]: "!=",
[TokenKind.LT]: "<", [TokenKind.GT]: ">",
[TokenKind.LTE]: "<=", [TokenKind.GTE]: ">=",
};
class Parser {
private i = 0;
private depth = 0;
constructor(private tokens: Token[]) {}
peek(): Token { return this.tokens[this.i]; }
next(): Token { return this.tokens[this.i++]; }
expect(kind: TokenKind, msg: string): Token {
const t = this.peek();
if (t.kind !== kind) {
throw new FormulaParseError([{
code: "UNEXPECTED_TOKEN", message: msg, span: { start: t.start, end: t.end },
}]);
}
return this.next();
}
// Bound recursive descent. Every path that recurses (parens, unary chains,
// binary rhs, call args) funnels through parseExpr/parseUnary, so guarding
// their entry caps the JS stack and turns pathological nesting into a
// catchable FormulaParseError instead of a RangeError.
private enter(): void {
if (++this.depth > MAX_PARSE_DEPTH) {
const t = this.peek();
throw new FormulaParseError([{
code: "DEPTH_EXCEEDED",
message: `Formula nesting too deep (max ${MAX_PARSE_DEPTH})`,
span: { start: t.start, end: t.end },
}]);
}
}
parseExpr(minBp: number): RawFormulaAST {
this.enter();
try {
return this.parseExprInner(minBp);
} finally {
this.depth--;
}
}
private parseExprInner(minBp: number): RawFormulaAST {
let lhs = this.parseUnary();
while (true) {
const tok = this.peek();
if (tok.kind === TokenKind.AND) {
if (BP[TokenKind.AND]! < minBp) break;
this.next();
const rhs = this.parseExpr(BP[TokenKind.AND]! + 1);
lhs = { t: "and", args: [lhs, rhs] };
continue;
}
if (tok.kind === TokenKind.OR) {
if (BP[TokenKind.OR]! < minBp) break;
this.next();
const rhs = this.parseExpr(BP[TokenKind.OR]! + 1);
lhs = { t: "or", args: [lhs, rhs] };
continue;
}
const bp = BP[tok.kind];
if (bp == null || bp < minBp) break;
this.next();
const rhs = this.parseExpr(bp + 1);
const op = TOK_TO_OP[tok.kind]!;
lhs = { t: "op", op, args: [lhs, rhs] };
}
return lhs;
}
parseUnary(): RawFormulaAST {
const tok = this.peek();
if (tok.kind === TokenKind.MINUS) {
this.next();
this.enter();
try {
return { t: "op", op: "neg", args: [this.parseUnary()] };
} finally {
this.depth--;
}
}
if (tok.kind === TokenKind.NOT) {
this.next();
this.enter();
try {
return { t: "op", op: "not", args: [this.parseUnary()] };
} finally {
this.depth--;
}
}
return this.parsePrimary();
}
parsePrimary(): RawFormulaAST {
const tok = this.next();
switch (tok.kind) {
case TokenKind.NUMBER: return { t: "num", v: Number(tok.text) };
case TokenKind.STRING: return { t: "str", v: tok.text };
case TokenKind.TRUE: return { t: "bool", v: true };
case TokenKind.FALSE: return { t: "bool", v: false };
case TokenKind.NULL: return { t: "null" };
case TokenKind.LPAREN: {
const e = this.parseExpr(0);
this.expect(TokenKind.RPAREN, "Expected ')'");
return e;
}
case TokenKind.AND:
case TokenKind.OR:
case TokenKind.IDENT: {
if (this.peek().kind !== TokenKind.LPAREN) {
throw new FormulaParseError([{
code: "UNEXPECTED_TOKEN",
message: `Unexpected identifier '${tok.text}' (did you mean prop("${tok.text}")?)`,
span: { start: tok.start, end: tok.end },
}]);
}
this.next(); // LPAREN
const args: RawFormulaAST[] = [];
if (this.peek().kind !== TokenKind.RPAREN) {
args.push(this.parseExpr(0));
while (this.peek().kind === TokenKind.COMMA) {
this.next();
args.push(this.parseExpr(0));
}
}
this.expect(TokenKind.RPAREN, "Expected ')'");
// Match special-form/keyword names case-insensitively; the `call`
// node below keeps the raw casing the user typed.
const head = tok.text.toLowerCase();
if (head === "prop") {
if (args.length !== 1 || args[0].t !== "str") {
throw new FormulaParseError([{
code: "UNEXPECTED_TOKEN",
message: 'prop() expects exactly one string literal argument',
span: { start: tok.start, end: tok.end },
}]);
}
return { t: "propName", name: args[0].v };
}
if (head === "if") {
if (args.length !== 3) {
throw new FormulaParseError([{
code: "ARITY_MISMATCH",
message: "if() expects exactly 3 arguments",
span: { start: tok.start, end: tok.end },
}]);
}
return { t: "if", cond: args[0], then: args[1], else: args[2] };
}
if (head === "and") return { t: "and", args };
if (head === "or") return { t: "or", args };
return { t: "call", fn: tok.text, args };
}
default:
throw new FormulaParseError([{
code: "UNEXPECTED_TOKEN",
message: `Unexpected token '${tok.text || tok.kind}'`,
span: { start: tok.start, end: tok.end },
}]);
}
}
}
+68
View File
@@ -0,0 +1,68 @@
import { FormulaParseError } from "./error";
import type { FormulaAST, RawFormulaAST } from "./ast";
export type ResolveResult = {
ast: FormulaAST;
dependencies: string[];
};
export function resolve(
raw: RawFormulaAST,
nameToId: ReadonlyMap<string, string>,
): ResolveResult {
const deps = new Set<string>();
const ast = walk(raw, nameToId, deps);
return { ast, dependencies: Array.from(deps).sort() };
}
function walk(
node: RawFormulaAST,
nameToId: ReadonlyMap<string, string>,
deps: Set<string>,
): FormulaAST {
switch (node.t) {
case "num": case "str": case "bool": case "null":
return node as FormulaAST;
case "propName": {
const id = nameToId.get(node.name);
if (!id) {
throw new FormulaParseError([{
code: "UNKNOWN_PROPERTY",
message: `Unknown property '${node.name}'`,
span: { start: 0, end: 0 }, // parser carries real spans; resolver is post-parse
}]);
}
deps.add(id);
return { t: "prop", id };
}
case "op":
return {
t: "op",
op: (node as any).op,
args: (node as any).args.map((a: RawFormulaAST) => walk(a, nameToId, deps)),
};
case "if":
return {
t: "if",
cond: walk((node as any).cond, nameToId, deps),
then: walk((node as any).then, nameToId, deps),
else: walk((node as any).else, nameToId, deps),
};
case "and":
return {
t: "and",
args: (node as any).args.map((a: RawFormulaAST) => walk(a, nameToId, deps)),
};
case "or":
return {
t: "or",
args: (node as any).args.map((a: RawFormulaAST) => walk(a, nameToId, deps)),
};
case "call":
return {
t: "call",
fn: (node as any).fn,
args: (node as any).args.map((a: RawFormulaAST) => walk(a, nameToId, deps)),
};
}
}
+158
View File
@@ -0,0 +1,158 @@
import { FormulaParseError } from "./error";
import { MAX_FORMULA_SOURCE_LENGTH } from "./types";
export enum TokenKind {
NUMBER = "NUMBER",
STRING = "STRING",
IDENT = "IDENT",
TRUE = "TRUE",
FALSE = "FALSE",
NULL = "NULL",
AND = "AND",
OR = "OR",
NOT = "NOT",
PLUS = "PLUS",
MINUS = "MINUS",
STAR = "STAR",
SLASH = "SLASH",
PERCENT = "PERCENT",
EQ = "EQ",
NEQ = "NEQ",
LT = "LT",
GT = "GT",
LTE = "LTE",
GTE = "GTE",
LPAREN = "LPAREN",
RPAREN = "RPAREN",
COMMA = "COMMA",
EOF = "EOF",
}
export type Token = {
kind: TokenKind;
text: string;
start: number;
end: number;
};
const KEYWORDS: Record<string, TokenKind> = {
true: TokenKind.TRUE,
false: TokenKind.FALSE,
null: TokenKind.NULL,
and: TokenKind.AND,
or: TokenKind.OR,
not: TokenKind.NOT,
};
export function tokenize(src: string): Token[] {
if (src.length > MAX_FORMULA_SOURCE_LENGTH) {
throw new FormulaParseError([{
code: "INPUT_TOO_LONG",
message: `Formula is too long (${src.length} chars; max ${MAX_FORMULA_SOURCE_LENGTH})`,
span: { start: 0, end: MAX_FORMULA_SOURCE_LENGTH },
}]);
}
const tokens: Token[] = [];
let i = 0;
const push = (kind: TokenKind, text: string, start: number, end: number) =>
tokens.push({ kind, text, start, end });
while (i < src.length) {
const ch = src[i];
if (ch === " " || ch === "\t" || ch === "\n" || ch === "\r") { i++; continue; }
if (ch >= "0" && ch <= "9") {
const start = i;
while (i < src.length && src[i] >= "0" && src[i] <= "9") i++;
if (src[i] === ".") {
i++;
while (i < src.length && src[i] >= "0" && src[i] <= "9") i++;
}
push(TokenKind.NUMBER, src.slice(start, i), start, i);
continue;
}
if (ch === '"' || ch === "'") {
const quote = ch;
const start = i;
i++;
let body = "";
while (i < src.length && src[i] !== quote) {
if (src[i] === "\\") {
if (i + 1 >= src.length) {
throw new FormulaParseError([{
code: "UNEXPECTED_EOF",
message: "Unterminated escape in string",
span: { start, end: i + 1 },
}]);
}
const esc = src[i + 1];
body += esc === "n" ? "\n" : esc === "t" ? "\t" : esc;
i += 2;
} else {
body += src[i];
i++;
}
}
if (i >= src.length) {
throw new FormulaParseError([{
code: "UNEXPECTED_EOF",
message: "Unterminated string literal",
span: { start, end: src.length },
}]);
}
i++;
push(TokenKind.STRING, body, start, i);
continue;
}
if ((ch >= "a" && ch <= "z") || (ch >= "A" && ch <= "Z") || ch === "_") {
const start = i;
while (
i < src.length &&
(
(src[i] >= "a" && src[i] <= "z") ||
(src[i] >= "A" && src[i] <= "Z") ||
(src[i] >= "0" && src[i] <= "9") ||
src[i] === "_"
)
) i++;
const text = src.slice(start, i);
// Keywords and function names are case-insensitive: match on the
// lowercased text but keep `text` raw on the token so error messages
// and `format()` preserve the user's casing.
// hasOwnProperty guards against inherited Object.prototype names
// (toString, valueOf, hasOwnProperty, …) matching the KEYWORDS lookup —
// those are valid identifiers/function names (e.g. the toString() fn).
const lower = text.toLowerCase();
const kw = Object.prototype.hasOwnProperty.call(KEYWORDS, lower) ? KEYWORDS[lower] : undefined;
push(kw ?? TokenKind.IDENT, text, start, i);
continue;
}
const start = i;
const two = src.slice(i, i + 2);
if (two === "==") { push(TokenKind.EQ, two, start, i + 2); i += 2; continue; }
if (two === "!=") { push(TokenKind.NEQ, two, start, i + 2); i += 2; continue; }
if (two === "<=") { push(TokenKind.LTE, two, start, i + 2); i += 2; continue; }
if (two === ">=") { push(TokenKind.GTE, two, start, i + 2); i += 2; continue; }
const singleMap: Record<string, TokenKind> = {
"+": TokenKind.PLUS, "-": TokenKind.MINUS, "*": TokenKind.STAR,
"/": TokenKind.SLASH, "%": TokenKind.PERCENT,
"<": TokenKind.LT, ">": TokenKind.GT,
"(": TokenKind.LPAREN, ")": TokenKind.RPAREN, ",": TokenKind.COMMA,
};
if (singleMap[ch]) { push(singleMap[ch], ch, start, i + 1); i++; continue; }
throw new FormulaParseError([{
code: "UNEXPECTED_TOKEN",
message: `Unexpected character '${ch}'`,
span: { start: i, end: i + 1 },
}]);
}
tokens.push({ kind: TokenKind.EOF, text: "", start: i, end: i });
return tokens;
}
+89
View File
@@ -0,0 +1,89 @@
import { FormulaParseError } from "./error";
import type { FormulaAST, OpCode } from "./ast";
import type { FormulaResultType } from "./types";
import type { FormulaFn } from "./functions";
export type PropertyTypeMap = ReadonlyMap<string, FormulaResultType>;
export type TypecheckResult = { resultType: FormulaResultType };
const ARITH_OPS: OpCode[] = ["+", "-", "*", "/", "%"];
const CMP_OPS: OpCode[] = ["==", "!=", ">", "<", ">=", "<="];
export function typecheck(
ast: FormulaAST,
propertyTypes: PropertyTypeMap,
registry: ReadonlyMap<string, FormulaFn>,
): TypecheckResult {
return { resultType: infer(ast, propertyTypes, registry) };
}
function infer(
ast: FormulaAST,
propertyTypes: PropertyTypeMap,
registry: ReadonlyMap<string, FormulaFn>,
): FormulaResultType {
switch (ast.t) {
case "num": return "number";
case "str": return "string";
case "bool": return "boolean";
case "null": return "null";
case "prop": return propertyTypes.get(ast.id) ?? "null";
case "op": {
const argTypes = ast.args.map((a) => infer(a, propertyTypes, registry));
if (ARITH_OPS.includes(ast.op)) {
// '+' is overloaded to match the evaluator: any string operand makes
// it string concatenation; otherwise it's numeric addition.
if (ast.op === "+" && argTypes.some((t) => t === "string")) return "string";
const allow = argTypes.every((t) => t === "number" || t === "null");
if (!allow) throw typeErr(`Operator '${ast.op}' needs numbers`);
return "number";
}
if (CMP_OPS.includes(ast.op)) return "boolean";
if (ast.op === "neg") {
if (argTypes[0] !== "number" && argTypes[0] !== "null") throw typeErr("Unary '-' needs number");
return "number";
}
if (ast.op === "not") {
if (argTypes[0] !== "boolean" && argTypes[0] !== "null") throw typeErr("'not' needs boolean");
return "boolean";
}
return "null";
}
case "if": {
const thenT = infer(ast.then, propertyTypes, registry);
const elseT = infer(ast.else, propertyTypes, registry);
if (thenT === elseT) return thenT;
if (thenT === "null") return elseT;
if (elseT === "null") return thenT;
throw typeErr(`if() branches have different types: ${thenT} vs ${elseT}`);
}
case "and": case "or":
ast.args.forEach((a) => {
const t = infer(a, propertyTypes, registry);
if (t !== "boolean" && t !== "null") throw typeErr(`'${ast.t}' needs boolean args`);
});
return "boolean";
case "call": {
const fn = registry.get(ast.fn.toLowerCase());
if (!fn) throw new FormulaParseError([{
code: "UNKNOWN_FUNCTION",
message: `Unknown function '${ast.fn}'`,
span: { start: 0, end: 0 },
}]);
const argTypes = ast.args.map((a) => infer(a, propertyTypes, registry));
if (argTypes.length < fn.arity.min || (fn.arity.max != null && argTypes.length > fn.arity.max)) {
throw new FormulaParseError([{
code: "ARITY_MISMATCH",
message: `${fn.name}() expects ${fn.arity.min}-${fn.arity.max ?? "∞"} args, got ${argTypes.length}`,
span: { start: 0, end: 0 },
}]);
}
return typeof fn.returnType === "function" ? fn.returnType(argTypes) : fn.returnType;
}
}
}
function typeErr(message: string): FormulaParseError {
return new FormulaParseError([{ code: "TYPE_MISMATCH", message, span: { start: 0, end: 0 } }]);
}
+74
View File
@@ -0,0 +1,74 @@
import type { FormulaAST } from "./ast";
export type FormulaResultType =
| "number"
| "string"
| "boolean"
| "date"
| "null";
export type FormulaTypeOptions = {
source: string;
ast: FormulaAST;
resultType: FormulaResultType;
dependencies: string[];
astVersion: 1;
formatOptions?: Record<string, unknown>;
};
/*
* The runtime value produced by evaluating a node. Strings and numbers are
* their JS equivalents; dates are ISO 8601 UTC strings (matches how the date
* property type already stores cells); booleans are booleans; missing or
* filtered-out values are null. Errors are distinguishable from all valid
* values because they are objects with a `__err` key.
*/
export type Value = number | string | boolean | null | ErrorCell;
export type ErrorCell = {
__err: ErrorCode;
msg: string;
v: 1;
};
export type ErrorCode =
| "MISSING_PROP"
| "TYPE_MISMATCH"
| "DIV_BY_ZERO"
| "DATE_INVALID"
| "DEPTH_EXCEEDED"
| "DEPENDENCY_ERROR";
/*
* EvalContext carries everything the evaluator needs that isn't in the AST:
* the function registry (server-only), the property map for resolving `prop`
* nodes to their formula ASTs when nested, and the current recursion depth.
*/
export type EvalContext = {
registry: ReadonlyMap<string, import("./functions/registry").FormulaFn>;
properties: ReadonlyMap<string, PropertyLookup>;
depth: number;
maxDepth: number;
memo: Map<string, Value>; // keyed by propId for the current row-eval
};
export type PropertyLookup = {
id: string;
type: string;
typeOptions: unknown;
};
export const DEFAULT_MAX_DEPTH = 64;
/*
* DoS guards. A formula source longer than MAX_FORMULA_SOURCE_LENGTH is
* rejected before tokenizing (cheap backstop against pathological input like
* "(".repeat(50000)). MAX_PARSE_DEPTH bounds recursive-descent nesting so a
* deeply nested source throws a catchable FormulaParseError instead of
* overflowing the JS stack with a RangeError. MAX_EVAL_DEPTH bounds the
* tree-walking evaluator so an oversized AST that slipped past the parser
* degrades to an error cell instead of crashing the recompute worker.
*/
export const MAX_FORMULA_SOURCE_LENGTH = 10_000;
export const MAX_PARSE_DEPTH = 256;
export const MAX_EVAL_DEPTH = 512;
+12
View File
@@ -0,0 +1,12 @@
{
"extends": "../editor-ext/tsconfig.json",
"compilerOptions": {
"outDir": "./dist",
"rootDir": "./src",
"declaration": true,
"composite": true,
"tsBuildInfoFile": "./dist/tsconfig.tsbuildinfo"
},
"include": ["src/**/*"],
"exclude": ["dist", "node_modules"]
}
+1
View File
@@ -2,6 +2,7 @@
"name": "@docmost/editor-ext",
"homepage": "https://docmost.com",
"private": true,
"sideEffects": false,
"scripts": {
"build": "tsc --build",
"dev": "tsc --watch"
+5
View File
@@ -34,3 +34,8 @@ export * from "./lib/status";
export * from "./lib/pdf";
export * from "./lib/page-break";
export * from "./lib/resizable-nodeview";
export {
pageNodeToDocxBuffer,
type DocxImageResolver,
} from "./lib/prosemirror-docx";
export * from "./lib/base-embed";
@@ -0,0 +1,133 @@
import { Node, mergeAttributes } from '@tiptap/core';
import { EditorState, NodeSelection, Plugin } from '@tiptap/pm/state';
export interface BaseEmbedOptions {
HTMLAttributes: Record<string, any>;
}
declare module '@tiptap/core' {
interface Commands<ReturnType> {
baseEmbed: {
insertBaseEmbed: (attrs: {
pageId: string | null;
pendingKey?: string | null;
}) => ReturnType;
};
}
}
export const BaseEmbed = Node.create<BaseEmbedOptions>({
name: 'base',
group: 'block',
atom: true,
selectable: true,
draggable: true,
addOptions() {
return { HTMLAttributes: {} };
},
// prosemirror-dropcursor draws a block-boundary indicator on every
// `dragover` it sees. Pragmatic-dnd (used for column / choice reorder
// inside the embed) fires native `dragstart`/`dragover`, which bubble
// up to the editor and trigger dropcursor — visible as a stray blue
// line above or below the embed during an internal drag. The cursor
// event lands over the atom node, so dropcursor consults
// `disableDropCursor` on this node spec; returning true suppresses
// the indicator while still letting pragmatic-dnd handle the drag.
extendNodeSchema(extension) {
return extension.name === 'base'
? { disableDropCursor: true }
: {};
},
addAttributes() {
return {
pageId: {
default: null,
parseHTML: (el) => el.getAttribute('data-page-id'),
renderHTML: (attrs) =>
attrs.pageId ? { 'data-page-id': attrs.pageId } : {},
},
// Transient marker set when the slash command inserts the embed
// before the server has assigned a pageId. The view renders a
// skeleton in this state. Cleared once the API responds and the
// real pageId is patched in. Not serialized — embeds saved with
// a pendingKey would orphan if the page were closed mid-request.
pendingKey: {
default: null,
parseHTML: () => null,
renderHTML: () => ({}),
},
};
},
parseHTML() {
return [{ tag: 'div[data-type="base-embed"]' }];
},
renderHTML({ HTMLAttributes }) {
return [
'div',
mergeAttributes(this.options.HTMLAttributes, HTMLAttributes, {
'data-type': 'base-embed',
}),
];
},
addCommands() {
return {
insertBaseEmbed:
(attrs) =>
({ commands }) =>
commands.insertContent({
type: this.name,
attrs,
}),
};
},
addKeyboardShortcuts() {
// Block Backspace / Delete when the base embed itself is the
// current selection — the "click on the embed and hit delete"
// accidental-delete path. Returning true tells TipTap we've
// handled the key, preventing the default removal. Range
// selections covering the node and programmatic deletes still
// work normally.
const isThisNodeSelected = (): boolean => {
const { selection } = this.editor.state;
return (
selection instanceof NodeSelection &&
selection.node.type.name === this.name
);
};
return {
Backspace: () => isThisNodeSelected(),
Delete: () => isThisNodeSelected(),
};
},
addProseMirrorPlugins() {
// Same idea as the Backspace/Delete shortcuts above, but for the
// other accidental-delete path: when the embed is the selection,
// a typed character or paste would replace the whole node. These
// hooks return true (handled, no-op) so the node stays put. The
// user can still press an arrow key to deselect and then type.
const nodeName = this.name;
const isThisNodeSelected = (state: EditorState): boolean => {
const { selection } = state;
return (
selection instanceof NodeSelection &&
selection.node.type.name === nodeName
);
};
return [
new Plugin({
props: {
handleTextInput: (view) => isThisNodeSelected(view.state),
handlePaste: (view) => isThisNodeSelected(view.state),
},
}),
];
},
});
@@ -0,0 +1,2 @@
export { BaseEmbed } from './base-embed';
export type { BaseEmbedOptions } from './base-embed';
@@ -0,0 +1,167 @@
# `prosemirror-docx`
[![prosemirror-docx on npm](https://img.shields.io/npm/v/prosemirror-docx.svg)](https://www.npmjs.com/package/prosemirror-docx)
[![prosemirror-docx on GitHub](https://img.shields.io/github/stars/curvenote/prosemirror-docx.svg?style=social)](https://github.com/curvenote/prosemirror-docx)
[
[![MIT License](https://img.shields.io/badge/license-MIT-blue.svg)](https://github.com/curvenote/prosemirror-docx/blob/master/LICENSE)
![CI](https://github.com/curvenote/prosemirror-docx/workflows/CI/badge.svg)
Export a [prosemirror](https://prosemirror.net/) document to a Microsoft Word file, using [docx](https://docx.js.org/).
![image](https://user-images.githubusercontent.com/913249/134953610-886047eb-2a21-4929-9a53-9a29d8f6184f.png)
## Overview
`prosemirror-docx` has a similar structure to [prosemirror-markdown](https://github.com/prosemirror/prosemirror-markdown), with a `DocxSerializerState` object that you write to as you walk the document. It is a light wrapper around <https://docx.js.org/>, which actually does the export. Currently `prosemirror-docx` is write only (i.e. can export to, but cant read from `*.docx`), and has most of the basic nodes covered (see below).
[Curvenote](https://curvenote.com) uses this to export from [@curvenote/editor](https://github.com/curvenote/editor) to word docs, but this library currently only has dependence on `docx`, `prosemirror-model` and `image-dimensions` - and similar to `prosemirror-markdown`, the serialization schema can be edited externally (see `Extended usage` below).
## Basic usage
```ts
import { defaultDocxSerializer, writeDocx } from 'prosemirror-docx';
import { EditorState } from 'prosemirror-state';
import { writeFileSync } from 'fs'; // Or some other way to write a file
// Set up your prosemirror state/document as you normally do
const state = EditorState.create({ schema: mySchema });
// If there are images, we will need to preload the buffers
const opts = {
getImageBuffer(src: string) {
return anImageBuffer;
},
};
// Create a doc in memory, and then write it to disk
const wordDocument = defaultDocxSerializer.serialize(state.doc, opts);
await writeDocx(wordDocument).then((buffer) => {
writeFileSync('HelloWorld.docx', buffer);
});
```
### Advanced usage
If you need to access the underlying state and modify the final docx `Document` you can use the last argument of `serialize` to pass in a callback function that receives the `DocxSerializerState`.
This function needs to return an `IPropertiesOptions` type, ie. the config that should be passed to a `Document`. Your options will be spread with the default options, so you can override any of the defaults.
```ts
const wordDocument = defaultDocxSerializer.serialize(state.doc, opts, (state) => {
return {
numbering: {
config: state.numbering,
},
fonts: [], // embed fonts,
styles: {
paragraphStyles,
default: {
heading1: paragraphStyles[1],
},
},
};
});
```
See the [docx documentation](https://docx.js.org/#/usage/document) for more details on the options you can pass in.
## Extended usage
Instead of using the `defaultDocxSerializer` you can override or provide custom serializers.
```ts
import { DocxSerializer, defaultNodes, defaultMarks } from 'prosemirror-docx';
const nodeSerializer = {
...defaultNodes,
my_paragraph(state, node) {
state.renderInline(node);
state.closeBlock(node);
},
};
export const myDocxSerializer = new DocxSerializer(nodeSerializer, defaultMarks);
```
The `state` is the `DocxSerializerState` and has helper methods to interact with `docx`.
If the exported content includes image links that require fetching the image data, you can use asynchronous APIs. Here's a demo example:
```ts
import { DocxSerializerAsync, defaultAsyncNodes, defaultMarks } from 'prosemirror-docx';
import { EditorState } from 'prosemirror-state';
import { writeFileSync } from 'fs';
const state = EditorState.create({ schema: mySchema });
export const docxSerializer = new DocxSerializerAsync(
{
...defaultAsyncNodes,
async image(state, node) {
const { src } = node.attrs;
await state.image(src, 70, 'center', undefined, 'png');
state.closeBlock(node);
},
},
defaultMarks,
);
// If there are images, we will need to preload the buffers
const opts = {
async getImageBuffer(src: string) {
const arrayBuffer = await fetch(src).then((res) => res.arrayBuffer());
return new Uint8Array(arrayBuffer);
},
};
// Create a doc in memory, and then write it to disk
const wordDocument = docxSerializer.serializeAsync(state.doc, opts);
await writeDocx(wordDocument).then((buffer) => {
writeFileSync('HelloWorld.docx', buffer);
});
```
## Supported Nodes
- text
- paragraph
- heading (levels)
- TODO: Support numbering of headings
- blockquote
- code_block
- TODO: No styles supported
- horizontal_rule
- hard_break
- ordered_list
- unordered_list
- list_item
- image
- math
- equations (numbered & unnumbered)
- tables
Planned:
- Internal References (e.g. see Table 1)
## Supported Marks
- em
- strong
- link
- Note: this is actually treated as a node in docx, so ignored as a prosemirror mark, but supported.
- code
- subscript
- superscript
- strikethrough
- underline
- smallcaps
- allcaps
## Resources
- [Prosemirror Docs](https://prosemirror.net/docs/)
- [docx](https://docx.js.org/)
- [prosemirror-markdown](https://github.com/ProseMirror/prosemirror-markdown) - similar implementation for markdown!
@@ -0,0 +1,24 @@
// MIT - https://github.com/curvenote/prosemirror-docx/
export type { SectionConfig, SerializationState } from './types';
export type {
MarkSerializer,
NodeSerializer,
NodeSerializerAsync,
Options,
OptionsAsync,
} from './serializer';
export {
DocxSerializerStateAsync,
DocxSerializerAsync,
DocxSerializerState,
DocxSerializer,
MAX_IMAGE_WIDTH,
} from './serializer';
export {
defaultAsyncNodes,
defaultMarks,
pageNodeToDocxBuffer,
type DocxImageResolver,
} from './schema';
export { writeDocx, createDocFromState, buildDoc } from './utils';
@@ -0,0 +1,47 @@
import { AlignmentType, convertInchesToTwip, ILevelsOptions, LevelFormat } from 'docx';
import { INumbering } from './types';
function basicIndentStyle(indent: number): Pick<ILevelsOptions, 'style' | 'alignment'> {
return {
alignment: AlignmentType.START,
style: {
paragraph: {
indent: { left: convertInchesToTwip(indent), hanging: convertInchesToTwip(0.18) },
},
},
};
}
const numbered = Array(3)
.fill([LevelFormat.DECIMAL, LevelFormat.LOWER_LETTER, LevelFormat.LOWER_ROMAN])
.flat()
.map((format, level) => ({
level,
format,
text: `%${level + 1}.`,
...basicIndentStyle((level + 1) / 2),
}));
const bullets = Array(3)
.fill(['●', '○', '■'])
.flat()
.map((text, level) => ({
level,
format: LevelFormat.BULLET,
text,
...basicIndentStyle((level + 1) / 2),
}));
const styles = {
numbered,
bullets,
};
export type NumberingStyles = keyof typeof styles;
export function createNumbering(reference: string, style: NumberingStyles): INumbering {
return {
reference,
levels: styles[style],
};
}
@@ -0,0 +1,293 @@
import { HeadingLevel, ShadingType } from 'docx';
import { Node } from 'prosemirror-model';
import {
DocxSerializerAsync,
MarkSerializer,
NodeSerializerAsync,
OptionsAsync,
} from './serializer';
import { writeDocx } from './utils';
export type DocxImageResolver = OptionsAsync['getImageBuffer'];
// docx requires a 6-digit hex color (no leading #). Convert #rgb, #rrggbb,
// and rgb()/rgba() inputs to 6-digit hex; return undefined for anything else
// (named colors, hsl, etc.) so the caller omits the color rather than letting
// docx throw "Invalid hex value".
function toDocxColor(input?: string): string | undefined {
if (!input) return undefined;
const value = input.trim().toLowerCase();
const hex = value.startsWith('#') ? value.slice(1) : value;
if (/^[0-9a-f]{6}$/.test(hex)) return hex;
if (/^[0-9a-f]{3}$/.test(hex)) {
return hex
.split('')
.map((ch) => ch + ch)
.join('');
}
const rgb = value.match(/^rgba?\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)/);
if (rgb) {
const channel = (n: string) =>
Math.max(0, Math.min(255, parseInt(n, 10)))
.toString(16)
.padStart(2, '0');
return channel(rgb[1]) + channel(rgb[2]) + channel(rgb[3]);
}
return undefined;
}
// Images and diagrams embed via the image resolver; the URL (with its file
// extension) is passed through so docx can infer the image type.
const renderImage: NodeSerializerAsync[string] = async (state, node) => {
const src = node.attrs?.src || node.attrs?.attachmentId;
if (src) {
try {
await state.image(src, 100);
} catch {
// Unrenderable/missing image: skip rather than fail the whole export.
}
}
state.closeBlock(node);
};
// Non-embeddable media render as a labelled line.
const renderFileLine: NodeSerializerAsync[string] = (state, node) => {
const label =
node.attrs?.name || node.attrs?.src || node.attrs?.url || 'attachment';
state.text(label);
state.closeBlock(node);
};
const renderEmbedLine: NodeSerializerAsync[string] = (state, node) => {
const label = node.attrs?.src || node.attrs?.url || 'embed';
state.text(label);
state.closeBlock(node);
};
export const defaultAsyncNodes: NodeSerializerAsync = {
text(state, node) {
state.text(node.text ?? '');
},
async paragraph(state, node) {
await state.renderInline(node);
state.closeBlock(node);
},
async heading(state, node) {
await state.renderInline(node);
const heading = [
HeadingLevel.HEADING_1,
HeadingLevel.HEADING_2,
HeadingLevel.HEADING_3,
HeadingLevel.HEADING_4,
HeadingLevel.HEADING_5,
HeadingLevel.HEADING_6,
][(node.attrs.level ?? 1) - 1];
state.closeBlock(node, { heading });
},
async blockquote(state, node) {
await state.renderContent(node, { style: 'IntenseQuote' });
},
async codeBlock(state, node) {
await state.renderContent(node);
state.closeBlock(node);
},
horizontalRule(state, node) {
state.closeBlock(node, { thematicBreak: true });
state.closeBlock(node);
},
hardBreak(state) {
state.addRunOptions({ break: 1 });
},
async bulletList(state, node) {
await state.renderList(node, 'bullets');
},
async orderedList(state, node) {
await state.renderList(node, 'numbered');
},
async listItem(state, node) {
await state.renderListItem(node);
},
async taskList(state, node) {
await state.renderList(node, 'bullets');
},
async taskItem(state, node) {
if (state.currentNumbering) {
state.addParagraphOptions({ numbering: state.currentNumbering });
}
state.text(node.attrs?.checked ? '☑ ' : '☐ ');
await state.renderContent(node);
},
async table(state, node) {
await state.table(node);
},
// Docmost stores LaTeX in attrs.text.
mathInline(state, node) {
state.math(node.attrs?.text ?? '', { inline: true });
},
mathBlock(state, node) {
state.math(node.attrs?.text ?? '', { inline: false, numbered: false });
state.closeBlock(node);
},
image: renderImage,
drawio: renderImage,
excalidraw: renderImage,
video: renderFileLine,
audio: renderFileLine,
pdf: renderFileLine,
attachment: renderFileLine,
embed: renderEmbedLine,
youtube: renderEmbedLine,
async callout(state, node) {
await state.renderContent(node, { style: 'IntenseQuote' });
},
async details(state, node) {
await state.renderContent(node);
},
async detailsSummary(state, node) {
await state.renderInline(node);
state.closeBlock(node, { heading: HeadingLevel.HEADING_4 });
},
async detailsContent(state, node) {
await state.renderContent(node);
},
async columns(state, node) {
await state.renderContent(node);
},
async column(state, node) {
await state.renderContent(node);
},
async transclusionSource(state, node) {
await state.renderContent(node);
},
mention(state, node) {
state.text(`@${node.attrs?.label ?? ''}`);
},
status(state, node) {
state.text(`[${node.attrs?.text ?? ''}]`);
},
pageBreak(state, node) {
state.closeBlock(node, { pageBreakBefore: true });
},
// No usable static export representation: skip without failing.
subpages() {},
transclusionReference() {},
};
export const defaultMarks: MarkSerializer = {
bold() {
return { bold: true };
},
italic() {
return { italics: true };
},
strike() {
return { strike: true };
},
underline() {
return { underline: {} };
},
code() {
return {
font: { name: 'Monospace' },
color: '000000',
shading: { type: ShadingType.SOLID, color: 'D2D3D2', fill: 'D2D3D2' },
};
},
superscript() {
return { superScript: true };
},
subscript() {
return { subScript: true };
},
link() {
// Handled specifically in the serializer; Word treats links as nodes.
return {};
},
highlight(_state, _node, mark) {
const fill = toDocxColor(mark.attrs?.color);
return fill
? { shading: { type: ShadingType.CLEAR, fill } }
: { highlight: 'yellow' };
},
// @tiptap/extension-color stores the color on the textStyle mark.
textStyle(_state, _node, mark) {
const color = toDocxColor(mark.attrs?.color);
return color ? { color } : {};
},
// Comments are editor-only; drop the annotation in the export.
comment() {
return {};
},
};
export async function pageNodeToDocxBuffer(
doc: Node,
getImageBuffer: DocxImageResolver,
): Promise<Buffer> {
const serializer = new DocxSerializerAsync(defaultAsyncNodes, defaultMarks);
const wordDoc = await serializer.serializeAsync(
doc,
{ getImageBuffer },
// docx's built-in heading styles are blue (#2E74B5 / #1F4D78). The editor
// has no heading color, so override the default heading run colors to the
// normal text color. Sizes/italics mirror docx's own defaults so only the
// color changes.
() =>
({
styles: {
default: {
document: { paragraph: { spacing: { after: 160 } } },
heading1: {
run: { color: '000000', size: 32 },
paragraph: {
keepNext: true,
keepLines: true,
spacing: { before: 240, after: 0 },
},
},
heading2: {
run: { color: '000000', size: 26 },
paragraph: {
keepNext: true,
keepLines: true,
spacing: { before: 40, after: 0 },
},
},
heading3: {
run: { color: '000000', size: 24 },
paragraph: {
keepNext: true,
keepLines: true,
spacing: { before: 40, after: 0 },
},
},
heading4: {
run: { color: '000000', italics: true },
paragraph: {
keepNext: true,
keepLines: true,
spacing: { before: 40, after: 0 },
},
},
heading5: {
run: { color: '000000' },
paragraph: {
keepNext: true,
keepLines: true,
spacing: { before: 40, after: 0 },
},
},
heading6: {
run: { color: '000000' },
paragraph: {
keepNext: true,
keepLines: true,
spacing: { before: 40, after: 0 },
},
},
},
},
}) as any,
);
return writeDocx(wordDoc);
}
@@ -0,0 +1,925 @@
import { Node, Mark } from 'prosemirror-model';
import {
IParagraphOptions,
IRunOptions,
Paragraph,
TextRun,
ExternalHyperlink,
ParagraphChild,
MathRun,
Math,
TabStopType,
TabStopPosition,
SequentialIdentifier,
Bookmark,
ImageRun,
AlignmentType,
Table,
TableRow,
TableCell,
ITableCellOptions,
InternalHyperlink,
SimpleField,
FootnoteReferenceRun,
IImageOptions,
Document,
ITableOptions,
ITableRowOptions,
IPropertiesOptions,
} from 'docx';
import { imageDimensionsFromData } from 'image-dimensions';
import { createNumbering, NumberingStyles } from './numbering';
import { buildDoc, createShortId } from './utils';
import { IFootnotes, INumbering, Mutable, SectionConfig, SerializationState } from './types';
// This is duplicated from @curvenote/schema
export type AlignOptions = 'left' | 'center' | 'right';
export type NodeSerializer = Record<
string,
(state: DocxSerializerState, node: Node, parent: Node, index: number) => void
>;
export type NodeSerializerAsync = Record<
string,
(state: DocxSerializerStateAsync, node: Node, parent: Node, index: number) => void | Promise<void>
>;
export type MarkSerializer = Record<
string,
(state: DocxSerializerState | DocxSerializerStateAsync, node: Node, mark: Mark) => IRunOptions
>;
export type Options = {
getImageBuffer: (src: string) => Uint8Array;
sections?: SectionConfig[];
};
export type OptionsAsync = {
getImageBuffer: (src: string) => Uint8Array | Promise<Uint8Array>;
sections?: SectionConfig[];
};
export type IMathOpts = {
inline?: boolean;
id?: string | null;
numbered?: boolean;
};
export type ImageType = 'jpg' | 'png' | 'gif' | 'bmp';
export const MAX_IMAGE_WIDTH = 600;
function createReferenceBookmark(
id: string,
kind: 'Equation' | 'Figure' | 'Table',
before?: string,
after?: string,
) {
const textBefore = before ? [new TextRun(before)] : [];
const textAfter = after ? [new TextRun(after)] : [];
return new Bookmark({
id,
children: [...textBefore, new SequentialIdentifier(kind), ...textAfter],
});
}
export class DocxSerializerState {
nodes: NodeSerializer;
options: Options;
marks: MarkSerializer;
children: (Paragraph | Table)[];
sections: Array<{
config: SectionConfig;
children: (Paragraph | Table)[];
}>;
currentSectionIndex = 0;
numbering: INumbering[];
footnotes: IFootnotes = {};
nextRunOpts?: IRunOptions;
current: ParagraphChild[] = [];
currentLink?: { link: string; children: IRunOptions[] };
// Optionally add options
nextParentParagraphOpts?: IParagraphOptions;
currentNumbering?: { reference: string; level: number };
constructor(nodes: NodeSerializer, marks: MarkSerializer, options: Options) {
this.nodes = nodes;
this.marks = marks;
this.options = options ?? ({} as Options);
this.children = [];
this.numbering = [];
// Initialize sections
if (options.sections && options.sections.length > 0) {
this.sections = options.sections.map((config) => ({
config,
children: [],
}));
this.children = this.sections[0].children;
} else {
this.sections = [];
}
}
renderContent(parent: Node, opts?: IParagraphOptions) {
parent.forEach((node, _, i) => {
if (opts) this.addParagraphOptions(opts);
this.render(node, parent, i);
});
}
render(node: Node, parent: Node, index: number) {
if (typeof parent === 'number') throw new Error('!');
if (!this.nodes[node.type.name])
throw new Error(`Token type \`${node.type.name}\` not supported by Word renderer`);
this.nodes[node.type.name](this, node, parent, index);
}
renderMarks(node: Node, marks: Mark[]): IRunOptions {
return marks
.map((mark) => {
return this.marks[mark.type.name]?.(this, node, mark);
})
.reduce((a, b) => ({ ...a, ...b }), {});
}
renderInline(parent: Node) {
// Pop the stack over to this object when we encounter a link, and closeLink restores it
let currentLink: { link: string; stack: ParagraphChild[] } | undefined;
const closeLink = () => {
if (!currentLink) return;
const hyperlink = new ExternalHyperlink({
link: currentLink.link,
// child: this.current[0],
children: this.current,
});
this.current = [...currentLink.stack, hyperlink];
currentLink = undefined;
};
const openLink = (href: string) => {
const sameLink = href === currentLink?.link;
this.addRunOptions({ style: 'Hyperlink' });
// TODO: https://github.com/dolanmiu/docx/issues/1119
// Remove the if statement here and oneLink!
const oneLink = true;
if (!oneLink) {
closeLink();
} else {
if (currentLink && sameLink) return;
if (currentLink && !sameLink) {
// Close previous, and open a new one
closeLink();
}
}
currentLink = {
link: href,
stack: this.current,
};
this.current = [];
};
const progress = (node: Node, offset: number, index: number) => {
const links = node.marks.filter((m) => m.type.name === 'link');
const hasLink = links.length > 0;
if (hasLink) {
openLink(links[0].attrs.href);
} else if (!hasLink && currentLink) {
closeLink();
}
if (node.isText) {
this.text(node.text, this.renderMarks(node, [...node.marks]));
} else {
this.render(node, parent, index);
}
};
parent.forEach(progress);
// Must call close at the end of everything, just in case
closeLink();
}
renderList(node: Node, style: NumberingStyles) {
if (!this.currentNumbering) {
const nextId = createShortId();
this.numbering.push(createNumbering(nextId, style));
this.currentNumbering = { reference: nextId, level: 0 };
} else {
const { reference, level } = this.currentNumbering;
this.currentNumbering = { reference, level: level + 1 };
}
this.renderContent(node);
if (this.currentNumbering.level === 0) {
delete this.currentNumbering;
} else {
const { reference, level } = this.currentNumbering;
this.currentNumbering = { reference, level: level - 1 };
}
}
// This is a pass through to the paragraphs, etc. underneath they will close the block
renderListItem(node: Node) {
if (!this.currentNumbering) throw new Error('Trying to create a list item without a list?');
this.addParagraphOptions({ numbering: this.currentNumbering });
this.renderContent(node);
}
addParagraphOptions(opts: IParagraphOptions) {
this.nextParentParagraphOpts = { ...this.nextParentParagraphOpts, ...opts };
}
addRunOptions(opts: IRunOptions) {
this.nextRunOpts = { ...this.nextRunOpts, ...opts };
}
text(text: string | null | undefined, opts?: IRunOptions) {
if (!text) return;
this.current.push(new TextRun({ text, ...this.nextRunOpts, ...opts }));
delete this.nextRunOpts;
}
math(latex: string, opts: IMathOpts = { inline: true }) {
if (opts.inline || !opts.numbered) {
this.current.push(new Math({ children: [new MathRun(latex)] }));
return;
}
const id = opts.id ?? createShortId();
this.current = [
new TextRun('\t'),
new Math({
children: [new MathRun(latex)],
}),
new TextRun('\t('),
createReferenceBookmark(id, 'Equation'),
new TextRun(')'),
];
this.addParagraphOptions({
tabStops: [
{
type: TabStopType.CENTER,
position: TabStopPosition.MAX / 2,
},
{
type: TabStopType.RIGHT,
position: TabStopPosition.MAX,
},
],
});
}
// not sure what this actually is, seems to be close for 8.5x11
maxImageWidth = MAX_IMAGE_WIDTH;
image(
src: string,
widthPercent = 70,
align: AlignOptions = 'center',
imageRunOpts?: IImageOptions,
imageType?: ImageType,
) {
const buffer = this.options.getImageBuffer(src);
const dimensions = imageDimensionsFromData(buffer);
/* If the image is not a valid image, don't add it */
if (!dimensions) return;
const aspect = dimensions.height / dimensions.width;
const width = this.maxImageWidth * (widthPercent / 100);
let it;
try {
it = imageType || (src.replace(/.*\./, '').toLowerCase() as any);
} catch (e) {
it = 'png';
}
this.current.push(
new ImageRun({
data: buffer,
...imageRunOpts,
type: it,
transformation: {
...(imageRunOpts?.transformation || {}),
width,
height: width * aspect,
},
}),
);
let alignment: string;
switch (align) {
case 'right':
alignment = AlignmentType.RIGHT;
break;
case 'left':
alignment = AlignmentType.LEFT;
break;
default:
alignment = AlignmentType.CENTER;
}
this.addParagraphOptions({
alignment: alignment as any,
});
}
table(
node: Node,
opts: {
getCellOptions?: (cell: Node) => ITableCellOptions;
getRowOptions?: (row: Node) => Omit<ITableRowOptions, 'children'>;
tableOptions?: Omit<ITableOptions, 'rows'>;
} = {},
) {
const { getCellOptions, getRowOptions, tableOptions } = opts;
const actualChildren = this.children;
const rows: TableRow[] = [];
node.content.forEach((row) => {
const cells: TableCell[] = [];
// Check if all cells are headers in this row
let tableHeader = true;
row.content.forEach((cell) => {
if (cell.type.name !== 'tableHeader') {
tableHeader = false;
}
});
// This scales images inside of tables
this.maxImageWidth = MAX_IMAGE_WIDTH / row.content.childCount;
row.content.forEach((cell) => {
this.children = [];
this.renderContent(cell);
const tableCellOpts: Mutable<ITableCellOptions> = { children: this.children };
const colspan = cell.attrs.colspan ?? 1;
const rowspan = cell.attrs.rowspan ?? 1;
if (colspan > 1) tableCellOpts.columnSpan = colspan;
if (rowspan > 1) tableCellOpts.rowSpan = rowspan;
cells.push(
new TableCell({
...tableCellOpts,
...(getCellOptions?.(cell) || {}),
}),
);
});
rows.push(new TableRow({ ...(getRowOptions?.(row) || {}), children: cells, tableHeader }));
});
this.maxImageWidth = MAX_IMAGE_WIDTH;
const table = new Table({ ...tableOptions, rows });
actualChildren.push(table);
// If there are multiple tables, this seperates them
actualChildren.push(new Paragraph(''));
this.children = actualChildren;
}
captionLabel(id: string, kind: 'Figure' | 'Table', { suffix } = { suffix: ': ' }) {
this.current.push(...[createReferenceBookmark(id, kind, `${kind} `), new TextRun(suffix)]);
}
$footnoteCounter = 0;
footnote(node: Node) {
const { current, nextRunOpts } = this;
// Delete everything and work with the footnote inline on the current
this.current = [];
delete this.nextRunOpts;
this.$footnoteCounter += 1;
this.renderInline(node);
this.footnotes[this.$footnoteCounter] = {
children: [new Paragraph({ children: this.current })],
};
this.current = current;
this.nextRunOpts = nextRunOpts;
this.current.push(new FootnoteReferenceRun(this.$footnoteCounter));
}
closeBlock(node: Node, props?: IParagraphOptions) {
const paragraph = new Paragraph({
children: this.current,
...this.nextParentParagraphOpts,
...props,
});
this.current = [];
delete this.nextParentParagraphOpts;
this.children.push(paragraph);
}
/**
* Move to the next section. If no more sections are available,
* this will be ignored (content continues in current section).
*/
nextSection() {
if (this.currentSectionIndex < this.sections.length - 1) {
this.currentSectionIndex += 1;
this.children = this.sections[this.currentSectionIndex].children;
}
}
/**
* Update the current section's configuration
*/
setSectionConfig(config: Partial<SectionConfig>) {
this.sections[this.currentSectionIndex].config = {
...this.sections[this.currentSectionIndex].config,
...config,
};
}
/**
* Add a new section with the given configuration and switch to it
*/
addSection(config: SectionConfig = {}) {
this.sections.push({
config,
children: [],
});
this.currentSectionIndex = this.sections.length - 1;
this.children = this.sections[this.currentSectionIndex].children;
}
/**
* Get the current section index
*/
getCurrentSectionIndex(): number {
return this.currentSectionIndex;
}
/**
* Get the current section configuration
*/
getCurrentSectionConfig(): SectionConfig {
return this.sections[this.currentSectionIndex].config;
}
/**
* Get the current serialization state for document creation
*/
getSerializationState(): SerializationState {
return {
numbering: this.numbering,
sections: this.sections,
footnotes: this.footnotes,
};
}
createReference(id: string, before?: string, after?: string) {
const children: ParagraphChild[] = [];
if (before) children.push(new TextRun(before));
children.push(new SimpleField(`REF ${id} \\h`));
if (after) children.push(new TextRun(after));
const ref = new InternalHyperlink({ anchor: id, children });
this.current.push(ref);
}
}
export class DocxSerializer {
nodes: NodeSerializer;
marks: MarkSerializer;
constructor(nodes: NodeSerializer, marks: MarkSerializer) {
this.nodes = nodes;
this.marks = marks;
}
serialize(
content: Node,
options: Options,
getDocumentOptions?: (state: SerializationState) => IPropertiesOptions,
): Document {
const state = new DocxSerializerState(this.nodes, this.marks, options);
state.renderContent(content);
return buildDoc(state, getDocumentOptions?.(state));
}
}
export class DocxSerializerStateAsync {
nodes: NodeSerializerAsync;
options: OptionsAsync;
marks: MarkSerializer;
children: (Paragraph | Table)[];
sections: Array<{
config: SectionConfig;
children: (Paragraph | Table)[];
}>;
currentSectionIndex = 0;
numbering: INumbering[];
footnotes: IFootnotes = {};
nextRunOpts?: IRunOptions;
current: ParagraphChild[] = [];
currentLink?: { link: string; children: IRunOptions[] };
// Optionally add options
nextParentParagraphOpts?: IParagraphOptions;
currentNumbering?: { reference: string; level: number };
constructor(nodes: NodeSerializerAsync, marks: MarkSerializer, options: OptionsAsync) {
this.nodes = nodes;
this.marks = marks;
this.options = options ?? ({} as OptionsAsync);
this.children = [];
this.numbering = [];
// Initialize sections
if (options.sections && options.sections.length > 0) {
this.sections = options.sections.map((config) => ({
config,
children: [],
}));
this.children = this.sections[0].children;
} else {
this.sections = [];
}
}
async renderContent(parent: Node, opts?: IParagraphOptions) {
for (let i = 0; i < parent.childCount; i += 1) {
const node = parent.child(i);
if (opts) this.addParagraphOptions(opts);
// eslint-disable-next-line no-await-in-loop
await this.render(node, parent, i);
}
}
async render(node: Node, parent: Node, index: number) {
if (typeof parent === 'number') throw new Error('!');
if (!this.nodes[node.type.name])
throw new Error(`Token type \`${node.type.name}\` not supported by Word renderer`);
await Promise.resolve(this.nodes[node.type.name](this, node, parent, index));
}
renderMarks(node: Node, marks: Mark[]): IRunOptions {
return marks
.map((mark) => {
return this.marks[mark.type.name]?.(this, node, mark);
})
.reduce((a, b) => ({ ...a, ...b }), {});
}
async renderInline(parent: Node) {
// Pop the stack over to this object when we encounter a link, and closeLink restores it
let currentLink: { link: string; stack: ParagraphChild[] } | undefined;
const closeLink = () => {
if (!currentLink) return;
const hyperlink = new ExternalHyperlink({
link: currentLink.link,
// child: this.current[0],
children: this.current,
});
this.current = [...currentLink.stack, hyperlink];
currentLink = undefined;
};
const openLink = (href: string) => {
const sameLink = href === currentLink?.link;
this.addRunOptions({ style: 'Hyperlink' });
// TODO: https://github.com/dolanmiu/docx/issues/1119
// Remove the if statement here and oneLink!
const oneLink = true;
if (!oneLink) {
closeLink();
} else {
if (currentLink && sameLink) return;
if (currentLink && !sameLink) {
// Close previous, and open a new one
closeLink();
}
}
currentLink = {
link: href,
stack: this.current,
};
this.current = [];
};
const progress = async (node: Node, offset: number, index: number) => {
const links = node.marks.filter((m) => m.type.name === 'link');
const hasLink = links.length > 0;
if (hasLink) {
openLink(links[0].attrs.href);
} else if (!hasLink && currentLink) {
closeLink();
}
if (node.isText) {
this.text(node.text, this.renderMarks(node, [...node.marks]));
} else {
await this.render(node, parent, index);
}
};
// Process nodes sequentially to maintain order
for (let i = 0; i < parent.childCount; i += 1) {
// eslint-disable-next-line no-await-in-loop
await progress(parent.child(i), 0, i);
}
// Must call close at the end of everything, just in case
closeLink();
}
async renderList(node: Node, style: NumberingStyles) {
if (!this.currentNumbering) {
const nextId = createShortId();
this.numbering.push(createNumbering(nextId, style));
this.currentNumbering = { reference: nextId, level: 0 };
} else {
const { reference, level } = this.currentNumbering;
this.currentNumbering = { reference, level: level + 1 };
}
await this.renderContent(node);
if (this.currentNumbering.level === 0) {
delete this.currentNumbering;
} else {
const { reference, level } = this.currentNumbering;
this.currentNumbering = { reference, level: level - 1 };
}
}
// This is a pass through to the paragraphs, etc. underneath they will close the block
async renderListItem(node: Node) {
if (!this.currentNumbering) throw new Error('Trying to create a list item without a list?');
this.addParagraphOptions({ numbering: this.currentNumbering });
await this.renderContent(node);
}
addParagraphOptions(opts: IParagraphOptions) {
this.nextParentParagraphOpts = { ...this.nextParentParagraphOpts, ...opts };
}
addRunOptions(opts: IRunOptions) {
this.nextRunOpts = { ...this.nextRunOpts, ...opts };
}
text(text: string | null | undefined, opts?: IRunOptions) {
if (!text) return;
this.current.push(new TextRun({ text, ...this.nextRunOpts, ...opts }));
delete this.nextRunOpts;
}
math(latex: string, opts: IMathOpts = { inline: true }) {
if (opts.inline || !opts.numbered) {
this.current.push(new Math({ children: [new MathRun(latex)] }));
return;
}
const id = opts.id ?? createShortId();
this.current = [
new TextRun('\t'),
new Math({
children: [new MathRun(latex)],
}),
new TextRun('\t('),
createReferenceBookmark(id, 'Equation'),
new TextRun(')'),
];
this.addParagraphOptions({
tabStops: [
{
type: TabStopType.CENTER,
position: TabStopPosition.MAX / 2,
},
{
type: TabStopType.RIGHT,
position: TabStopPosition.MAX,
},
],
});
}
// not sure what this actually is, seems to be close for 8.5x11
maxImageWidth = MAX_IMAGE_WIDTH;
async image(
src: string,
widthPercent = 70,
align: AlignOptions = 'center',
imageRunOpts?: IImageOptions,
imageType?: ImageType,
) {
const buffer = await Promise.resolve(this.options.getImageBuffer(src));
const dimensions = imageDimensionsFromData(buffer);
/* If the image is not a valid image, don't add it */
if (!dimensions) return;
const aspect = dimensions.height / dimensions.width;
const width = this.maxImageWidth * (widthPercent / 100);
let it;
try {
it = imageType || (src.replace(/.*\./, '').toLowerCase() as any);
} catch (e) {
it = 'png';
}
this.current.push(
new ImageRun({
data: buffer,
...imageRunOpts,
type: it,
transformation: {
...(imageRunOpts?.transformation || {}),
width,
height: width * aspect,
},
}),
);
let alignment: string;
switch (align) {
case 'right':
alignment = AlignmentType.RIGHT;
break;
case 'left':
alignment = AlignmentType.LEFT;
break;
default:
alignment = AlignmentType.CENTER;
}
this.addParagraphOptions({
alignment: alignment as any,
});
}
async table(
node: Node,
opts: {
getCellOptions?: (cell: Node) => ITableCellOptions;
getRowOptions?: (row: Node) => Omit<ITableRowOptions, 'children'>;
tableOptions?: Omit<ITableOptions, 'rows'>;
} = {},
) {
const { getCellOptions, getRowOptions, tableOptions } = opts;
const actualChildren = this.children;
const rows: TableRow[] = [];
for (let rowIndex = 0; rowIndex < node.content.childCount; rowIndex += 1) {
const row = node.content.child(rowIndex);
const cells: TableCell[] = [];
// Check if all cells are headers in this row
let tableHeader = true;
// Check if all cells in the row are headers
for (let cellIndex = 0; cellIndex < row.content.childCount; cellIndex += 1) {
const cell = row.content.child(cellIndex);
if (cell.type.name !== 'tableHeader') {
tableHeader = false;
}
}
// This scales images inside of tables
this.maxImageWidth = MAX_IMAGE_WIDTH / row.content.childCount;
// Iterate through cells and ensure order
for (let cellIndex = 0; cellIndex < row.content.childCount; cellIndex += 1) {
const cell = row.content.child(cellIndex);
this.children = [];
// eslint-disable-next-line no-await-in-loop
await this.renderContent(cell); // Ensure order
const tableCellOpts: Mutable<ITableCellOptions> = { children: this.children };
const colspan = cell.attrs.colspan ?? 1;
const rowspan = cell.attrs.rowspan ?? 1;
if (colspan > 1) tableCellOpts.columnSpan = colspan;
if (rowspan > 1) tableCellOpts.rowSpan = rowspan;
cells.push(
new TableCell({
...tableCellOpts,
...(getCellOptions?.(cell) || {}),
}),
);
}
rows.push(new TableRow({ ...(getRowOptions?.(row) || {}), children: cells, tableHeader }));
}
this.maxImageWidth = MAX_IMAGE_WIDTH;
const table = new Table({ ...tableOptions, rows });
actualChildren.push(table);
// If there are multiple tables, this separates them
actualChildren.push(new Paragraph(''));
this.children = actualChildren;
}
captionLabel(id: string, kind: 'Figure' | 'Table', { suffix } = { suffix: ': ' }) {
this.current.push(...[createReferenceBookmark(id, kind, `${kind} `), new TextRun(suffix)]);
}
$footnoteCounter = 0;
async footnote(node: Node) {
const { current, nextRunOpts } = this;
// Delete everything and work with the footnote inline on the current
this.current = [];
delete this.nextRunOpts;
this.$footnoteCounter += 1;
await this.renderInline(node);
this.footnotes[this.$footnoteCounter] = {
children: [new Paragraph({ children: this.current })],
};
this.current = current;
this.nextRunOpts = nextRunOpts;
this.current.push(new FootnoteReferenceRun(this.$footnoteCounter));
}
closeBlock(node: Node, props?: IParagraphOptions) {
const paragraph = new Paragraph({
children: this.current,
...this.nextParentParagraphOpts,
...props,
});
this.current = [];
delete this.nextParentParagraphOpts;
this.children.push(paragraph);
}
/**
* Move to the next section. If no more sections are available,
* this will be ignored (content continues in current section).
*/
nextSection() {
if (this.currentSectionIndex < this.sections.length - 1) {
this.currentSectionIndex += 1;
this.children = this.sections[this.currentSectionIndex].children;
}
}
/**
* Update the current section's configuration
*/
setSectionConfig(config: Partial<SectionConfig>) {
this.sections[this.currentSectionIndex].config = {
...this.sections[this.currentSectionIndex].config,
...config,
};
}
/**
* Add a new section with the given configuration and switch to it
*/
addSection(config: SectionConfig = {}) {
this.sections.push({
config,
children: [],
});
this.currentSectionIndex = this.sections.length - 1;
this.children = this.sections[this.currentSectionIndex].children;
}
/**
* Get the current section index
*/
getCurrentSectionIndex(): number {
return this.currentSectionIndex;
}
/**
* Get the current section configuration
*/
getCurrentSectionConfig(): SectionConfig {
return this.sections[this.currentSectionIndex].config;
}
/**
* Get the current serialization state for document creation
*/
getSerializationState(): SerializationState {
return {
numbering: this.numbering,
sections: this.sections,
footnotes: this.footnotes,
};
}
createReference(id: string, before?: string, after?: string) {
const children: ParagraphChild[] = [];
if (before) children.push(new TextRun(before));
children.push(new SimpleField(`REF ${id} \\h`));
if (after) children.push(new TextRun(after));
const ref = new InternalHyperlink({ anchor: id, children });
this.current.push(ref);
}
}
export class DocxSerializerAsync {
nodes: NodeSerializerAsync;
marks: MarkSerializer;
constructor(nodes: NodeSerializerAsync, marks: MarkSerializer) {
this.nodes = nodes;
this.marks = marks;
}
async serializeAsync(
content: Node,
options: OptionsAsync,
getDocumentOptions?: (state: SerializationState) => IPropertiesOptions,
) {
const state = new DocxSerializerStateAsync(this.nodes, this.marks, options);
await state.renderContent(content);
return buildDoc(state, getDocumentOptions?.(state));
}
}
@@ -0,0 +1,34 @@
import { INumberingOptions, Paragraph, ISectionOptions } from 'docx';
export type Mutable<T> = {
-readonly [k in keyof T]: T[k];
};
export type IFootnotes = Mutable<
Readonly<
Record<
string,
{
readonly children: readonly Paragraph[];
}
>
>
>;
export type INumbering = INumberingOptions['config'][0];
export interface SectionConfig {
properties?: ISectionOptions['properties'];
headers?: ISectionOptions['headers'];
footers?: ISectionOptions['footers'];
}
export interface SerializationState {
numbering: INumberingOptions['config'];
sections?: Array<{
config: SectionConfig;
children: ISectionOptions['children'];
}>;
children?: ISectionOptions['children'];
footnotes?: IFootnotes;
}
@@ -0,0 +1,91 @@
import {
Document,
INumberingOptions,
IPropertiesOptions,
ISectionOptions,
Packer,
SectionType,
} from 'docx';
import { Node as ProsemirrorNode } from 'prosemirror-model';
import { IFootnotes, SerializationState } from './types';
export function createShortId() {
return Math.random().toString(36).slice(2, 11);
}
export function buildDoc(state: SerializationState, opts?: IPropertiesOptions): Document {
let sections = state?.sections?.length
? state.sections.map((section) => ({
properties: section.config.properties || {
type: SectionType.CONTINUOUS,
},
headers: section.config.headers,
footers: section.config.footers,
children: section.children,
}))
: undefined;
if (!sections) {
sections = [
{
headers: undefined,
footers: undefined,
properties: {
type: SectionType.CONTINUOUS,
},
children: state?.children || [],
},
];
}
const doc = new Document({
footnotes: state.footnotes,
numbering: {
config: state.numbering,
},
sections,
...(opts || {}),
});
return doc;
}
/**
* @deprecated - use `buildDoc` instead
* Creates a docx document from the given state.
* */
export function createDocFromState(state: {
numbering: INumberingOptions['config'];
children: ISectionOptions['children'];
footnotes?: IFootnotes;
}) {
return buildDoc({
numbering: state.numbering,
sections: [
{
config: {},
children: state.children,
},
],
footnotes: state.footnotes,
});
}
export async function writeDocx(
doc: Document,
/**
* @deprecated use `.then()` or `await` instead
*/
write?: ((buffer: Buffer) => void) | ((buffer: Buffer) => Promise<void>),
) {
const buffer = await Packer.toBuffer(doc);
await write?.(buffer);
return buffer;
}
export function getLatexFromNode(node: ProsemirrorNode): string {
let math = '';
node.forEach((child) => {
if (child.isText) math += child.text;
// TODO: improve this as we may have other things in the future
});
return math;
}
@@ -422,6 +422,8 @@ export const SearchAndReplace = Extension.create<
state: {
init: () => DecorationSet.empty,
apply({ doc, docChanged }, oldState) {
const storage = editor.storage.searchAndReplace;
if (!storage) return oldState;
const {
searchTerm,
lastSearchTerm,
@@ -429,7 +431,7 @@ export const SearchAndReplace = Extension.create<
lastCaseSensitive,
resultIndex,
lastResultIndex,
} = editor.storage.searchAndReplace;
} = storage;
if (
!docChanged &&
@@ -1 +1,2 @@
export { TableHeaderPin } from './extension';
export { pinOffsetWatcher, EDITOR_PIN_OFFSET_VAR, computePinTop } from './offset';