Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions .changeset/soft-clowns-push.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
---
"@foldworks/pdf": minor
"@foldworks/pdf-viewer": minor
"@foldworks/text-intelligence": minor
"@foldworks/diagram": minor
"@foldworks/diff-viewer": minor
"@foldworks/ui": minor
"@foldworks/data-grid": minor
---

Add generic document-analysis building blocks: positioned PDF text and explicit quote location, multi-region passage highlights, selectable PDF text and bounded viewport rendering, overlap-safe highlighted text and context snippets, read-only graph exploration, word/sentence prose diffs, citation previews, and per-cell grid tones.
5 changes: 4 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -129,7 +129,8 @@ rows and the code editor, placeholder rows, structure policy, a structural tree
diff, and a lazily loaded value tree.

See [planned work-surface primitives](./docs/planned-primitives.md) for the
next layers of the library to explore.
next layers of the library to explore, and [document analysis](./docs/document-analysis.md)
for available primitives for checking extracted values against their source documents.

## WorldVM

Expand Down Expand Up @@ -305,3 +306,5 @@ package-boundary decisions are documented in [Desktop primitives](packages/ui/do
The outliner and native Markdown editor share plain-text mentions and tags. See
the [design and adapter contract](./docs/mentions-and-tags.md) for host lookup,
syntax exclusions, stable identity, and persistence.

[Document analysis demo](https://foldworks.dev/document-analysis) composes PDF passage highlights and text selection, transcript ranges, prose diffs, citation previews, and a read-only relationship graph using synthetic documents.
112 changes: 112 additions & 0 deletions apps/demo/e2e/document-analysis.scenarios.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
import type { Page } from "playwright";
import { describe, expect, it } from "vitest";

export const documentAnalysisScenarios = (
getPage: () => Page,
appUrl: string,
screenshot: (name: string) => Promise<void>,
) => {
describe("document analysis", () => {
it("links cross-page evidence, selects PDF text, and bounds rendering across jumps and remounts", async () => {
const page = getPage();
await page.emulateMedia({ reducedMotion: "reduce" });
await page.goto(`${appUrl}/document-analysis`, { waitUntil: "networkidle" });
const pages = page.locator("[data-pdf-viewer-page]");
const images = page.locator(".fk-pdf-viewer__page-image");
await expect.poll(() => pages.count()).toBe(12);
await expect.poll(() => pages.nth(0).getAttribute("data-state")).toBe("rendered");
expect(await images.count()).toBeLessThanOrEqual(3);
expect(await pages.nth(11).getAttribute("data-state")).toBe("pending");
const citation = page.locator('[data-citation="1"]');
await citation.focus();
await expect.poll(() => page.getByRole("tooltip").isVisible()).toBe(true);
await citation.press("Escape");
await expect.poll(() => page.getByRole("tooltip").count()).toBe(0);
await citation.press("Enter");
const highlight = page.locator('button[data-pdf-overlay-key="quote"]');
await expect.poll(() => highlight.getAttribute("aria-pressed")).toBe("true");
expect(await highlight.count()).toBe(1);
expect(await page.locator('[data-pdf-overlay-key="quote"]').count()).toBe(2);
const selectText = async () => {
await page
.locator('[data-pdf-viewer-page="0"] [data-pdf-text-layer] span')
.filter({ hasText: "Employees with" })
.evaluate((element) => {
const range = document.createRange();
range.setStart(element.firstChild!, 0);
range.setEnd(element.firstChild!, 9);
const selection = document.getSelection()!;
selection.removeAllRanges();
selection.addRange(range);
document.dispatchEvent(new PointerEvent("pointerup"));
});
await expect
.poll(() => page.locator("[data-pdf-selected-text]").textContent())
.toBe("Selected: Employees");
};
await selectText();
await page.getByRole("button", { name: "Jump to last page" }).click();
await expect.poll(() => pages.nth(11).getAttribute("data-state")).toBe("rendered");
expect(await images.count()).toBeLessThanOrEqual(3);
await expect.poll(() => pages.nth(0).locator("[data-pdf-text-layer] span").count()).toBe(0);
await citation.click();
await expect.poll(() => pages.nth(0).getAttribute("data-state")).toBe("rendered");
await selectText();
await page.getByRole("link", { name: "Document editor", exact: true }).click();
await page.getByRole("link", { name: "Document analysis", exact: true }).click();
await expect.poll(() => pages.nth(0).getAttribute("data-state")).toBe("rendered");
await selectText();
expect(await images.count()).toBeLessThanOrEqual(3);
await citation.click();
// Wait for the citation to place both sides of the page boundary in the viewport.
await expect
.poll(async () => {
const rect = await highlight.boundingBox();
const viewer = await page.locator("[data-pdf-viewer-scroller]").boundingBox();
return (
rect !== null &&
viewer !== null &&
rect.y > viewer.y &&
rect.y < viewer.y + viewer.height
);
})
.toBe(true);
await screenshot("document-analysis");
});

it("compares prose and explores a graph using keyboard, compound disclosure, and neighborhoods", async () => {
const page = getPage();
await page.goto(`${appUrl}/document-analysis`, { waitUntil: "networkidle" });
const diff = page.locator(".fk-prose-diff");
await page.getByRole("button", { name: "Side-by-side comparison" }).click();
await expect.poll(() => diff.getAttribute("data-mode")).toBe("split");
expect(await diff.locator("ins").allTextContents()).toContain("six");
expect(await diff.locator("del").allTextContents()).toContain("may");
await page.getByRole("button", { name: "Fit graph", exact: true }).click();
const article = page.locator('[data-graph-node="article"]');
const policy = page.locator('[data-graph-node="policy"]');
await article.focus();
await article.press("ArrowDown");
await expect.poll(() => policy.getAttribute("aria-pressed")).toBe("true");
await expect
.poll(() => policy.evaluate((element) => element === document.activeElement))
.toBe(true);
await policy.press("Enter");
await expect
.poll(() => page.locator("[data-analysis-announcement]").textContent())
.toBe("Activated: policy");
await page.getByRole("button", { name: "Collapse Agreement", exact: true }).click();
await expect.poll(() => article.count()).toBe(0);
await page.getByRole("button", { name: "Expand Agreement", exact: true }).click();
await expect.poll(() => article.count()).toBe(1);
await page.getByRole("button", { name: "Focus Article 12 neighborhood" }).click();
await expect.poll(() => policy.count()).toBe(0);
await page.getByRole("button", { name: "Expand neighborhood", exact: true }).click();
await expect.poll(() => policy.count()).toBe(1);
await page.getByText("Graph as a list", { exact: true }).click();
expect(await page.locator("[data-graph-list-node]").count()).toBe(3);
await page.getByRole("button", { name: "Show all", exact: true }).click();
await expect.poll(() => page.locator("[data-graph-list-node]").count()).toBe(4);
});
});
};
28 changes: 21 additions & 7 deletions apps/demo/e2e/pdf-viewer.scenarios.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,13 +16,7 @@ export const pdfViewerScenarios = (
const pages = page.locator("[data-pdf-viewer-page]");
const controls = page.getByRole("toolbar", { name: "PDF controls" });
await expect.poll(() => pages.count()).toBe(3);
await expect
.poll(() =>
pages.evaluateAll((elements) =>
elements.map((element) => element.getAttribute("data-state")),
),
)
.toEqual(["rendered", "rendered", "rendered"]);
await expect.poll(() => pages.nth(0).getAttribute("data-state")).toBe("rendered");
await expect
.poll(() => page.getByText("Synthetic preview", { exact: true }).isVisible())
.toBe(true);
Expand All @@ -49,6 +43,26 @@ export const pdfViewerScenarios = (
.poll(() => controls.getByText("Page 3 of 3", { exact: true }).isVisible())
.toBe(true);

await expect.poll(() => rotatedPage.getAttribute("data-state")).toBe("rendered");
const title = rotatedPage
.locator("[data-pdf-text-layer] span")
.filter({ hasText: "Supplement B. Reverification" });
await expect.poll(() => title.count()).toBe(1);
// Measure both boxes in one frame while page navigation may still be animating.
const textGeometry = await title.evaluate((element) => {
const text = element.getBoundingClientRect();
const page = element.closest("[data-pdf-viewer-page]")!.getBoundingClientRect();
return {
x: (text.x - page.x) / page.width,
y: (text.y - page.y) / page.height,
width: text.width,
height: text.height,
};
});
expect(textGeometry.x).toBeCloseTo(72 / 792, 2);
expect(textGeometry.y).toBeGreaterThan(50 / 612);
expect(textGeometry.y).toBeLessThan(90 / 612);
expect(textGeometry.width).toBeGreaterThan(textGeometry.height * 5);
await controls.getByRole("button", { name: "Previous page" }).click();
await expect
.poll(() => controls.getByText("Page 2 of 3", { exact: true }).isVisible())
Expand Down
4 changes: 4 additions & 0 deletions apps/demo/e2e/workflow.e2e.test.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { documentAnalysisScenarios } from "./document-analysis.scenarios";
import { themeBuilderScenarios } from "./theme-builder.scenarios";
import { docsScenarios } from "./docs.scenarios";
import { desktopScenarios } from "./desktop.scenarios";
Expand Down Expand Up @@ -150,6 +151,7 @@ describe.sequential("structured workflow builder", () => {
"Form builder",
"Workflow builder",
"Diff review",
"Document analysis",
"PDF viewer",
"PDF annotator",
"Statechart editor",
Expand All @@ -158,6 +160,7 @@ describe.sequential("structured workflow builder", () => {
"Text intelligence",
"PDF rendering",
"UI system",
"Theme builder",
]);
await expect.poll(() => sidebar.getByText("Foldworks", { exact: true }).count()).toBe(1);
await expect.poll(() => sidebar.getByText("Design system", { exact: true }).count()).toBe(0);
Expand Down Expand Up @@ -1648,6 +1651,7 @@ describe.sequential("structured workflow builder", () => {
agentScenarios(() => page, appUrl, screenshot);
diffViewerScenarios(() => page, appUrl, screenshot);
pdfViewerScenarios(() => page, appUrl, screenshot);
documentAnalysisScenarios(() => page, appUrl, screenshot);
statechartScenarios(() => page, appUrl, screenshot);
outlinerScenarios(() => page, appUrl, screenshot);
orchestratorScenarios(() => page, appUrl, screenshot);
Expand Down
2 changes: 2 additions & 0 deletions apps/demo/src/app/message.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { Message as DocumentAnalysisMessage } from "../document-analysis/message";
import { Message as OrchestratorMessage } from "../orchestrator/message";
import { Schema as S } from "effect";
import { ActionIntent } from "@foldworks/generative-ui/core";
Expand Down Expand Up @@ -49,6 +50,7 @@ export const Message = defineMessageUnion({
GotQueryBuilderDemoMessage: { message: QueryBuilderDemoMessage },
GotOutlinerDemoMessage: { message: OutlinerDemoMessage },
GotPdfAnnotatorMessage: { message: PdfAnnotator.Message },
GotDocumentAnalysisMessage: { message: DocumentAnalysisMessage },
GotPdfViewerDemoMessage: { message: PdfViewerDemoMessage },
GotEditorMessage: { message: EditorMessage },
GotSidebarMessage: { message: Sidebar.Message },
Expand Down
6 changes: 6 additions & 0 deletions apps/demo/src/app/model.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,7 @@
import {
Model as DocumentAnalysisModel,
init as initDocumentAnalysis,
} from "../document-analysis/model";
import {
Model as OrchestratorModel,
initialModel as initialOrchestrator,
Expand Down Expand Up @@ -62,6 +66,7 @@ export const Model = S.Struct({
outlinerDemo: OutlinerDemoModel,
pdfAnnotator: PdfAnnotator.Model,
pdfViewerDemo: PdfViewerDemoModel,
documentAnalysis: DocumentAnalysisModel,
editor: ArticleEditor.Model,
sidebar: Sidebar.Model,
uiKit: UiKitModel,
Expand Down Expand Up @@ -106,6 +111,7 @@ export const init = (
sampleUrl: "/foldworks-sample.pdf",
}),
pdfViewerDemo: initialPdfViewer,
documentAnalysis: initDocumentAnalysis(),
editor: initialEditor(),
sidebar: Sidebar.init({ id: "foldworks-sidebar", defaultCollapsed: route._tag === "Theme" }),
uiKit: initialUiKit,
Expand Down
10 changes: 10 additions & 0 deletions apps/demo/src/app/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ export type Demo =
| "Outliner"
| "PdfAnnotator"
| "PdfViewer"
| "DocumentAnalysis"
| "UiKit"
| "Theme";

Expand Down Expand Up @@ -52,6 +53,7 @@ export const AppRoute = defineRouteUnion({
Outliner: {},
PdfAnnotator: {},
PdfViewer: {},
DocumentAnalysis: {},
UiKit: {},
Theme: {},
NotFound: { path: S.String },
Expand Down Expand Up @@ -140,6 +142,11 @@ export const pdfAnnotatorRouter = pipe(
Route.mapTo(AppRoute.PdfAnnotator),
);

export const documentAnalysisRouter = pipe(
Route.literal("document-analysis"),
Route.mapTo(AppRoute.DocumentAnalysis),
);

export const pdfViewerRouter = pipe(Route.literal("pdf-viewer"), Route.mapTo(AppRoute.PdfViewer));

export const editorRouter = pipe(Route.literal("editor"), Route.mapTo(AppRoute.Editor));
Expand All @@ -162,6 +169,7 @@ const routeParser = Route.oneOf(
outlinerRouter,
pdfAnnotatorRouter,
pdfViewerRouter,
documentAnalysisRouter,
uiKitRouter,
themeRouter,
homeRouter,
Expand Down Expand Up @@ -201,6 +209,8 @@ export const demoFromRoute = (route: AppRoute): Demo => {
return "Outliner";
case "PdfAnnotator":
return "PdfAnnotator";
case "DocumentAnalysis":
return "DocumentAnalysis";
case "PdfViewer":
return "PdfViewer";
case "Theme":
Expand Down
8 changes: 8 additions & 0 deletions apps/demo/src/app/subscriptions.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { subscriptions as documentAnalysisSubscriptions } from "../document-analysis/subscriptions";
import { subscriptions as orchestratorSubscriptions } from "../orchestrator/subscriptions";
import { Effect, Schema as S, Stream } from "effect";
import { PdfAnnotator } from "@foldworks/pdf-annotator";
Expand All @@ -6,7 +7,7 @@
import { subscriptions as agentSubscriptions } from "../agent/subscriptions";
import { subscriptions as workbenchSubscriptions } from "../workbench/subscriptions";
import { subscriptions as dataGridSubscriptions } from "../data-grid/subscriptions";
import { Message as DataGridMessage } from "../data-grid/message";

Check warning on line 10 in apps/demo/src/app/subscriptions.ts

View workflow job for this annotation

GitHub Actions / Validate packages and demo

eslint(no-unused-vars)

apps/demo/src/app/subscriptions.ts:10:21: Identifier 'DataGridMessage' is imported but never used.
import { subscriptions as formSubscriptions } from "../form-builder/subscriptions";
import { Message as FormMessage } from "../form-builder/message";
import { subscriptions as queryBuilderSubscriptions } from "../query-builder/subscriptions";
Expand Down Expand Up @@ -129,6 +130,12 @@
toParentMessage: (message) => Message.GotPdfAnnotatorMessage({ message }),
});

const documentAnalysis = Subscription.lift(documentAnalysisSubscriptions)<Model, Message>({
when: (model) => model.route._tag === "DocumentAnalysis",
toChildModel: (model) => model.documentAnalysis,
toParentMessage: (message) => Message.GotDocumentAnalysisMessage({ message }),
});

export const subscriptions = Subscription.aggregate<Model, Message>()(
workflow,
statechart,
Expand All @@ -139,6 +146,7 @@
workbench,
queryBuilder,
pdfAnnotator,
documentAnalysis,
themeSubscriptions,
historySubscriptions,
);
14 changes: 13 additions & 1 deletion apps/demo/src/app/update.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import { update as updateDocumentAnalysis } from "../document-analysis/update";
import { Message as DocumentAnalysisMessage } from "../document-analysis/message";
import { update as updateOrchestrator } from "../orchestrator/update";
import { update as updateDocs } from "../docs/update";
import { Effect, Option, Schema as S } from "effect";
Expand Down Expand Up @@ -167,7 +169,9 @@ export const enterRoute = (model: Model): UpdateReturn =>
? foldStatechart(model, StatechartMessage.ClickedFit())
: model.route._tag === "PdfViewer"
? enterPdfViewer(model)
: { model };
: model.route._tag === "DocumentAnalysis"
? foldDocumentAnalysis(model, DocumentAnalysisMessage.Initialize())
: { model };

const foldCodeEditor = Update.foldChild({
update: updateCodeEditor,
Expand Down Expand Up @@ -260,6 +264,13 @@ const foldPdfViewer = Update.foldChild({
toParentMessage: (message) => Message.GotPdfViewerDemoMessage({ message }),
});

const foldDocumentAnalysis = Update.foldChild({
update: updateDocumentAnalysis,
read: (model: Model) => Option.some(model.documentAnalysis),
write: (model, documentAnalysis) => ({ ...model, documentAnalysis }),
toParentMessage: (message) => Message.GotDocumentAnalysisMessage({ message }),
});

// Generate the sample PDF the first time the viewer demo opens.
const enterPdfViewer = (model: Model): UpdateReturn =>
model.pdfViewerDemo.viewer.document._tag === "Empty"
Expand Down Expand Up @@ -411,6 +422,7 @@ export const update = (model: Model, message: Message): UpdateReturn =>
foldQueryBuilder(model, childMessage),
GotOutlinerDemoMessage: ({ message: childMessage }) => foldOutliner(model, childMessage),
GotPdfAnnotatorMessage: ({ message: childMessage }) => foldPdfAnnotator(model, childMessage),
GotDocumentAnalysisMessage: ({ message }) => foldDocumentAnalysis(model, message),
GotPdfViewerDemoMessage: ({ message: childMessage }) => foldPdfViewer(model, childMessage),
GotEditorMessage: ({ message }) => foldEditor(model, message),
GotSidebarMessage: ({ message: childMessage }) => foldSidebar(model, childMessage),
Expand Down
Loading
Loading