From 1c055f3d1857e19e4e70c76436e95585abe83563 Mon Sep 17 00:00:00 2001 From: EthanHealy01 <80844253+EthanHealy01@users.noreply.github.com> Date: Fri, 28 Aug 2026 12:27:05 +0000 Subject: [PATCH 01/37] Centre modals in the viewport instead of pinning them near the top (#7715) ## What Every dialog in the processor is the shared `.sui-modal` shell, and its backdrop was top-aligning the panel: ```css align-items: flex-start; padding: 5rem 1.5rem 1.5rem; /* 80px above, 24px below */ ``` On a 900px-tall viewport that started every dialog at `y=80` with ~350px of dead space beneath it. Phones already had an `align-items: center` override; desktop never got one. ## Change `frontend/editor/src/core/ui/Modal.css` only: - Symmetric block inset, `align-items: center`. - The inset is published as `--modal-inset-block`, and `.sui-modal`'s `max-height` derives from it. That coupling is the point: if the two drift apart, a tall modal overflows a centre-aligned backdrop and loses its header off the top of the screen, unreachable. - The phone breakpoint now only moves the variable. Measured at 375x812 it resolves to exactly the previous values (`16px 12px`, `max-height: 780px`), so mobile behaviour is unchanged. One shared file, so this covers flow modals, source / user / pipeline / API-key modals, billing and procurement. ## Before / After image ## Testing - `task frontend:check` passes (lint + typecheck + 2356 tests). - Phone breakpoint measured directly in the browser, values match the previous behaviour. --- frontend/editor/src/core/ui/Modal.css | 24 ++++++++++++++---------- 1 file changed, 14 insertions(+), 10 deletions(-) diff --git a/frontend/editor/src/core/ui/Modal.css b/frontend/editor/src/core/ui/Modal.css index 2988ec920e..e41ffb0eda 100644 --- a/frontend/editor/src/core/ui/Modal.css +++ b/frontend/editor/src/core/ui/Modal.css @@ -1,11 +1,16 @@ +/* The inset is symmetric so the panel sits in the optical centre of the viewport rather than + riding the top edge. It is published as a var because .sui-modal's max-height has to be the + viewport minus both halves of it — if the two drift apart a tall modal overflows the backdrop + and, because the panel is centre-aligned, loses its header off the top of the screen. */ .sui-modal__backdrop { + --modal-inset-block: 2.5rem; position: fixed; inset: 0; background: rgba(0, 0, 0, 0.55); display: flex; - align-items: flex-start; + align-items: center; justify-content: center; - padding: 5rem 1.5rem 1.5rem; + padding: var(--modal-inset-block) 1.5rem; z-index: 100; animation: fadeIn 0.18s ease both; overscroll-behavior: contain; @@ -24,20 +29,19 @@ display: flex; flex-direction: column; width: 100%; - max-height: calc(100vh - 6.5rem); - max-height: calc(100dvh - 6.5rem); /* mobile browser chrome shrinks 100vh */ + max-height: calc(100vh - var(--modal-inset-block) * 2); + /* mobile browser chrome shrinks 100vh */ + max-height: calc(100dvh - var(--modal-inset-block) * 2); overflow: hidden; animation: scaleIn 0.2s cubic-bezier(0.4, 0, 0.2, 1) both; } -/* Phones: drop the tall top inset so the modal gets the vertical space */ +/* Phones: tighten the inset so the modal gets the vertical space. Only the variable moves — + the max-height above follows it, so the pair cannot fall out of step. */ @media (max-width: 30rem) { .sui-modal__backdrop { - padding: 1rem 0.75rem; - align-items: center; - } - .sui-modal { - max-height: calc(100dvh - 2rem); + --modal-inset-block: 1rem; + padding-inline: 0.75rem; } } From d3708c1e63f5161d1143b780f497c6f4b671f58a Mon Sep 17 00:00:00 2001 From: Reece Browne <74901996+reecebrowne@users.noreply.github.com> Date: Fri, 28 Aug 2026 13:02:47 +0000 Subject: [PATCH 02/37] Highlight the rail entry whose tool is open (#7723) --- .../shared/quickNav/QuickNavHostBridge.tsx | 3 ++ .../shared/quickNav/QuickNavRailHost.tsx | 4 +++ .../contexts/QuickNavHostContext.test.tsx | 31 +++++++++++++++++++ .../src/core/contexts/QuickNavHostContext.tsx | 7 +++++ frontend/editor/src/core/pages/HomePage.tsx | 1 + 5 files changed, 46 insertions(+) diff --git a/frontend/editor/src/core/components/shared/quickNav/QuickNavHostBridge.tsx b/frontend/editor/src/core/components/shared/quickNav/QuickNavHostBridge.tsx index 9a9101a826..d97674464e 100644 --- a/frontend/editor/src/core/components/shared/quickNav/QuickNavHostBridge.tsx +++ b/frontend/editor/src/core/components/shared/quickNav/QuickNavHostBridge.tsx @@ -22,6 +22,7 @@ export interface QuickNavHostBridgeProps { requestNavigation?: (go: () => void) => void; onGoToDefaultState?: () => void; onSelectTool?: (toolId: ToolId) => void; + activeTool?: ToolId | null; /** Merged over the reasons worked out here, for what only the app can see. */ toolReasons?: QuickNavToolReasons; } @@ -34,6 +35,7 @@ export function QuickNavHostBridge({ onOpenSettings, requestNavigation, onSelectTool, + activeTool = null, onGoToDefaultState, toolReasons, }: QuickNavHostBridgeProps) { @@ -59,6 +61,7 @@ export function QuickNavHostBridge({ signingBadge, portalAccess, readerMode, + activeTool, notificationsOpen, toolReasons: mergedToolReasons, }, diff --git a/frontend/editor/src/core/components/shared/quickNav/QuickNavRailHost.tsx b/frontend/editor/src/core/components/shared/quickNav/QuickNavRailHost.tsx index 3fba4d7e1f..e7a1b6e8c6 100644 --- a/frontend/editor/src/core/components/shared/quickNav/QuickNavRailHost.tsx +++ b/frontend/editor/src/core/components/shared/quickNav/QuickNavRailHost.tsx @@ -48,6 +48,8 @@ export function QuickNavRailHost() { else go(route); }; + const openingTool = (id: ToolId) => ({ current: host?.activeTool === id }); + const unusable = (id: ToolId) => { const reason = host?.toolReasons?.[id]; return { disabled: Boolean(reason), reason }; @@ -135,6 +137,7 @@ export function QuickNavRailHost() { icon: ( ), + ...openingTool("automate"), ...unusable("automate"), onClick: () => openTool("automate", "/automate"), }, @@ -146,6 +149,7 @@ export function QuickNavRailHost() { ), badge: host?.signingBadge, badgeTone: "warning", + ...openingTool("sharedSign"), ...unusable("sharedSign"), onClick: () => openTool("sharedSign", "/shared-sign"), }, diff --git a/frontend/editor/src/core/contexts/QuickNavHostContext.test.tsx b/frontend/editor/src/core/contexts/QuickNavHostContext.test.tsx index fc648cbfe5..0a348262fa 100644 --- a/frontend/editor/src/core/contexts/QuickNavHostContext.test.tsx +++ b/frontend/editor/src/core/contexts/QuickNavHostContext.test.tsx @@ -13,6 +13,7 @@ function Probe({ onRead }: { onRead: (value: unknown) => void }) { appMounted: host?.appMounted, chromeless: host?.chromeless, identity: host?.identity, + activeTool: host?.activeTool, openSettings: Boolean(host?.actions.current?.openSettings), }); return null; @@ -26,6 +27,11 @@ function App() { return null; } +function AppWithTool({ tool }: { tool: "automate" | null }) { + useRegisterQuickNavHost({ activeTool: tool }, {}); + return null; +} + function LoginRoute() { useSuppressQuickNavRail(); return null; @@ -71,6 +77,31 @@ describe("QuickNavHostContext", () => { expect(after.openSettings).toBe(false); }); + it("clears the open tool when the next app registers without one", () => { + let latest: Record = {}; + const view = render( + + (latest = value as Record)} + /> + + , + ); + expect(latest.activeTool).toBe("automate"); + + act(() => { + view.rerender( + + (latest = value as Record)} + /> + + , + ); + }); + expect(latest.activeTool).toBe(null); + }); + it("hides the bar while a route with no app chrome is on screen", () => { // appMounted is sticky, so it can't answer "is an app on screen now". const { view, read } = setup(); diff --git a/frontend/editor/src/core/contexts/QuickNavHostContext.tsx b/frontend/editor/src/core/contexts/QuickNavHostContext.tsx index 540ec8cd6c..1a19cffc6f 100644 --- a/frontend/editor/src/core/contexts/QuickNavHostContext.tsx +++ b/frontend/editor/src/core/contexts/QuickNavHostContext.tsx @@ -24,6 +24,7 @@ export interface QuickNavHostData { signingBadge: number; portalAccess: boolean; readerMode: boolean; + activeTool: ToolId | null; /** The app owns the panel; the rail's bell only reports its state. */ notificationsOpen: boolean; /** Translated; absent means usable. */ @@ -61,6 +62,7 @@ const EMPTY_DATA: QuickNavHostData = { signingBadge: 0, portalAccess: false, readerMode: false, + activeTool: null, notificationsOpen: false, hasSettings: false, }; @@ -90,6 +92,7 @@ export function QuickNavHostProvider({ children }: { children: ReactNode }) { merged.signingBadge === prev.signingBadge && merged.portalAccess === prev.portalAccess && merged.readerMode === prev.readerMode && + merged.activeTool === prev.activeTool && merged.notificationsOpen === prev.notificationsOpen && merged.hasSettings === prev.hasSettings && merged.identity?.displayName === prev.identity?.displayName && @@ -143,6 +146,7 @@ export function useRegisterQuickNavHost( signingBadge, portalAccess, readerMode, + activeTool, notificationsOpen, toolReasons, } = data; @@ -155,6 +159,8 @@ export function useRegisterQuickNavHost( signingBadge: signingBadge ?? 0, portalAccess: portalAccess ?? false, readerMode: readerMode ?? false, + // Cleared, not omitted as toolReasons is: a stale tool marks an entry. + activeTool: activeTool ?? null, notificationsOpen: notificationsOpen ?? false, // Omitted when unknown, so the last answer survives a re-fetch. ...(toolReasons ? { toolReasons } : {}), @@ -168,6 +174,7 @@ export function useRegisterQuickNavHost( signingBadge, portalAccess, readerMode, + activeTool, notificationsOpen, toolReasons, hasSettings, diff --git a/frontend/editor/src/core/pages/HomePage.tsx b/frontend/editor/src/core/pages/HomePage.tsx index efb6af40d2..6434acbc9e 100644 --- a/frontend/editor/src/core/pages/HomePage.tsx +++ b/frontend/editor/src/core/pages/HomePage.tsx @@ -525,6 +525,7 @@ export default function HomePage() { onSetReaderMode={setReaderMode} onGoToDefaultState={goToDefaultState} onSelectTool={handleToolSelect} + activeTool={selectedToolKey} toolReasons={quickNavToolReasons} /> From c22d9ecf5801a75c84da927788a5bce2092c56d9 Mon Sep 17 00:00:00 2001 From: ConnorYoh <40631091+ConnorYoh@users.noreply.github.com> Date: Sat, 29 Aug 2026 00:22:05 +0000 Subject: [PATCH 03/37] feat(editor): move the admin directory onto TanStack Query (#7726) # Description of Changes Step 5 of the TanStack Query rollout, covering the admin People, Teams and Team details screens. Follows #7264, #7283, #7285. ## The problem Two separate ones, in the same three files. **Reads.** Each section fetched and held its own copy of the same resources: People read the roster and the team list, Teams read the team list plus the roster again when its add-member modal opened, Team details read all three. Cost scaled with how many screens you visited rather than with how much data exists. **Writes.** Thirteen handlers each did the same five things by hand: set a processing flag, call the service, toast the outcome, dig a message out of an axios error, and reload their own slice. Refreshing was a convention, not a mechanism, and one handler had already forgotten it. ## The fix Three shared query keys (`adminUsers`, `teams`, `teamDetails`), and one `useAdminMutation` helper that every write is declared against: ```ts const createTeam = useAdminMutation({ write: (name: string) => teamService.createTeam(name), invalidates: ["teams"], success: t("workspace.teams.createTeam.success"), errorFallback: t("workspace.teams.createTeam.error"), onDone: () => { setNewTeamName(""); setCreateModalOpened(false); }, }); ``` Each write names the slices it disturbs, which is the part that only works when reads and writes are designed together: `createTeam` invalidates the team list, while a membership move invalidates the list, both teams' detail rows and the roster, because it genuinely changes all three. Invalidation refetches only mounted queries, so this costs nothing extra. The blanket "invalidate everything" helper survives in exactly one role: child components (invite, password change, seat update) that write through their own services, where the affected scopes are not visible from the call site. ## Why it is better, measured Request counts come from one harness driving `teams -> team details -> back -> people`, run against the branch point and against this branch. The assertion is committed, so it cannot silently regress. | | Before | After | |---|---|---| | Requests | 7 | **3** | | `getTeams` | 4 | **1** | | `getUsers` | 2 | **1** | | `getTeamDetails` | 1 | 1 | | Committed renders | 17 | **15** | Three is one per distinct resource, the floor for that sequence. The four `getTeams` were the Teams table, Team details fetching the same list for its "move to team" dropdown, the explicit refresh on the back button, and People. Renders barely move, which is expected: this changes where data lives, not how often React draws. It is reported because a caching change can quietly cost renders, and this one does not. On the code itself, across the three sections: | | | |---|---| | Net lines | **-216** | | `useState`/`useEffect` removed | **11**, none added | | Duplicated `isAxiosError` blocks | 13 to **1** | | `setProcessing` calls | 19 to **0** | `isAxiosError` is no longer imported by any of the three files. ## Bug fixed `disableMfaByAdmin` showed a success toast and never refreshed. The menu item renders only when `user.mfaEnabled` is true, so an admin disabled MFA, was told it worked, and watched the option stay on screen until a manual reload. It is covered by a test that fails if the invalidation is removed. ## Behaviour worth checking in review - A write no longer blocks its handler before closing the modal. The dialog closes when the write succeeds and the table updates when the refetch lands, rather than the button spinning through both. - Modal submit buttons now track their own mutation rather than one shared flag. Team details still derives a single busy flag, now from its five mutations rather than a `useState`, so its row actions disable together as before. - The per-handler `console.error` is kept, once, in the shared error path. ## Testing Five tests, each verified by breaking the implementation and confirming that one test, and only that one, fails: | Mutation | Caught by | |---|---| | Drop the shared stale window (`staleTime: 0`) | request-count test | | Make invalidation a no-op | write-visibility test | | Ignore the login-enabled gate | login-disabled test | | Stop invalidating after the MFA write | MFA-refresh test | | Fall back to the generic error message | server-message test | The write tests drive the real flows through their modals and menus rather than calling hooks directly. `task frontend:check` passes typecheck, lint and oxfmt, and 2383 of 2385 editor tests. The two failures, `workbenchSession.test.ts` and `notificationActions.test.tsx`, are untouched here and fail identically with this branch's changes reverted. ## Scope The three services keep their current shape; nothing outside these three sections and the new hook module changes. Child modals that write through their own services still refresh via the blanket helper, and converting those is separate work. --- frontend/editor/src/core/query/keys.ts | 4 + .../config/configSections/PeopleSection.tsx | 477 +++++++----------- .../configSections/TeamDetailsSection.tsx | 393 ++++++--------- .../config/configSections/TeamsSection.tsx | 216 +++----- .../config/configSections/adminReads.test.tsx | 295 +++++++++++ .../proprietary/hooks/useAdminDirectory.ts | 151 ++++++ 6 files changed, 885 insertions(+), 651 deletions(-) create mode 100644 frontend/editor/src/proprietary/components/shared/config/configSections/adminReads.test.tsx create mode 100644 frontend/editor/src/proprietary/hooks/useAdminDirectory.ts diff --git a/frontend/editor/src/core/query/keys.ts b/frontend/editor/src/core/query/keys.ts index 5354b56b63..d95f674011 100644 --- a/frontend/editor/src/core/query/keys.ts +++ b/frontend/editor/src/core/query/keys.ts @@ -1,5 +1,7 @@ /** Editor query keys: ["editor", , ...params]. */ export const qk = { + /** The admin directory payload: a different endpoint and shape to qk.users(). */ + adminUsers: () => ["editor", "adminUsers"] as const, appConfig: () => ["editor", "appConfig"] as const, endpointsAvailability: () => ["editor", "endpointsAvailability"] as const, endpointEnabled: (endpoint: string) => @@ -9,5 +11,7 @@ export const qk = { /** Keyed on the asking identity: two users must never share one answer. */ portalAccess: (userId: string | null) => ["editor", "portalAccess", userId] as const, + teamDetails: (teamId: number) => ["editor", "teamDetails", teamId] as const, + teams: () => ["editor", "teams"] as const, users: () => ["editor", "users"] as const, } as const; diff --git a/frontend/editor/src/proprietary/components/shared/config/configSections/PeopleSection.tsx b/frontend/editor/src/proprietary/components/shared/config/configSections/PeopleSection.tsx index d955cb341b..6b14d2b421 100644 --- a/frontend/editor/src/proprietary/components/shared/config/configSections/PeopleSection.tsx +++ b/frontend/editor/src/proprietary/components/shared/config/configSections/PeopleSection.tsx @@ -1,5 +1,4 @@ -import { useState, useEffect } from "react"; -import { isAxiosError } from "axios"; +import { useMemo, useState } from "react"; import { Trans, useTranslation } from "react-i18next"; import { Stack, @@ -20,14 +19,12 @@ import { import { Button } from "@app/ui/Button"; import { ActionIcon } from "@app/ui/ActionIcon"; import LocalIcon from "@app/components/shared/LocalIcon"; -import { alert } from "@app/components/toast"; import { userManagementService, User, } from "@app/services/userManagementService"; -import { teamService, Team } from "@app/services/teamService"; +import { type Team } from "@app/services/teamService"; import { Z_INDEX_OVER_CONFIG_MODAL } from "@app/styles/zIndex"; -import { useAppConfig } from "@app/contexts/AppConfigContext"; import InviteMembersModal from "@app/components/shared/InviteMembersModal"; import { useLoginRequired } from "@app/hooks/useLoginRequired"; import LoginRequiredBanner from "@app/components/shared/config/LoginRequiredBanner"; @@ -36,17 +33,109 @@ import UpdateSeatsButton from "@app/components/shared/UpdateSeatsButton"; import { useLicense } from "@app/contexts/LicenseContext"; import ChangeUserPasswordModal from "@app/components/shared/ChangeUserPasswordModal"; import { useAuth } from "@app/auth/UseSession"; +import { + useAdminUsers, + useTeams, + useAdminMutation, + useInvalidateAdminDirectory, +} from "@app/hooks/useAdminDirectory"; + +const EXAMPLE_USERS: User[] = [ + { + id: 1, + username: "admin", + email: "admin@example.com", + enabled: true, + roleName: "ROLE_ADMIN", + rolesAsString: "ROLE_ADMIN", + authenticationType: "password", + isActive: true, + lastRequest: Date.now(), + team: { id: 1, name: "Engineering" }, + }, + { + id: 2, + username: "john.doe", + email: "john.doe@example.com", + enabled: true, + roleName: "ROLE_USER", + rolesAsString: "ROLE_USER", + authenticationType: "password", + isActive: false, + lastRequest: Date.now() - 86400000, + team: { id: 1, name: "Engineering" }, + }, + { + id: 3, + username: "jane.smith", + email: "jane.smith@example.com", + enabled: true, + roleName: "ROLE_USER", + rolesAsString: "ROLE_USER", + authenticationType: "oauth", + isActive: true, + lastRequest: Date.now(), + team: { id: 2, name: "Marketing" }, + }, + { + id: 4, + username: "bob.wilson", + email: "bob.wilson@example.com", + enabled: false, + roleName: "ROLE_USER", + rolesAsString: "ROLE_USER", + authenticationType: "password", + isActive: false, + lastRequest: Date.now() - 604800000, + team: undefined, + }, +]; + +const EXAMPLE_TEAMS: Team[] = [ + { id: 1, name: "Engineering", userCount: 3 }, + { id: 2, name: "Marketing", userCount: 2 }, +]; + +const EXAMPLE_LICENSE = { + maxAllowedUsers: 10, + availableSlots: 6, + grandfatheredUserCount: 0, + licenseMaxUsers: 5, + premiumEnabled: true, + totalUsers: 4, +}; export default function PeopleSection() { const { t } = useTranslation(); - const { config } = useAppConfig(); const { loginEnabled } = useLoginRequired(); const { user: currentUser } = useAuth(); const navigate = useNavigate(); const { licenseInfo: globalLicenseInfo } = useLicense(); - const [users, setUsers] = useState([]); - const [teams, setTeams] = useState([]); - const [loading, setLoading] = useState(true); + const admin = useAdminUsers(loginEnabled); + const { data: fetchedTeams } = useTeams(loginEnabled); + const refreshDirectory = useInvalidateAdminDirectory(); + + // Session and MFA state arrive alongside the roster, keyed by username. + const fetchedUsers = useMemo(() => { + if (!admin.data) return []; + return admin.data.users.map((user) => ({ + ...user, + isActive: admin.data.userSessions[user.username] || false, + lastRequest: admin.data.userLastRequest[user.username] || undefined, + mfaEnabled: + ( + admin.data.userSettings?.[user.username] as + | Record + | undefined + )?.mfaEnabled === "true", + })); + }, [admin.data]); + + // Login off means the endpoints are not callable, so the table shows a + // worked example instead of an empty state. + const users = loginEnabled ? fetchedUsers : EXAMPLE_USERS; + const teams = loginEnabled ? (fetchedTeams ?? []) : EXAMPLE_TEAMS; + const loading = loginEnabled && admin.isPending; const [searchQuery, setSearchQuery] = useState(""); const [inviteModalOpened, setInviteModalOpened] = useState(false); const [editUserModalOpened, setEditUserModalOpened] = useState(false); @@ -54,19 +143,20 @@ export default function PeopleSection() { useState(false); const [passwordUser, setPasswordUser] = useState(null); const [selectedUser, setSelectedUser] = useState(null); - const [processing, setProcessing] = useState(false); - const [mailEnabled, setMailEnabled] = useState(false); - const [lockedUsers, setLockedUsers] = useState([]); - - // License information - const [licenseInfo, setLicenseInfo] = useState<{ - maxAllowedUsers: number; - availableSlots: number; - grandfatheredUserCount: number; - licenseMaxUsers: number; - premiumEnabled: boolean; - totalUsers: number; - } | null>(null); + const mailEnabled = loginEnabled ? (admin.data?.mailEnabled ?? false) : false; + const lockedUsers = loginEnabled ? (admin.data?.lockedUsers ?? []) : []; + const licenseInfo = loginEnabled + ? admin.data + ? { + maxAllowedUsers: admin.data.maxAllowedUsers, + availableSlots: admin.data.availableSlots, + grandfatheredUserCount: admin.data.grandfatheredUserCount, + licenseMaxUsers: admin.data.licenseMaxUsers, + premiumEnabled: admin.data.premiumEnabled, + totalUsers: admin.data.totalUsers, + } + : null + : EXAMPLE_LICENSE; const hasNoSlots = licenseInfo ? licenseInfo.availableSlots === 0 : false; const handleAddMembersClick = () => { if (!loginEnabled) { @@ -115,253 +205,103 @@ export default function PeopleSection() { teamId: undefined as number | undefined, }); - useEffect(() => { - fetchData(); - }, []); + const updateUserRole = useAdminMutation({ + write: (payload: { username: string; role: string; teamId?: number }) => + userManagementService.updateUserRole(payload), + // A role edit can also move the user, which changes both teams' counts. + invalidates: ["users", "teams"], + success: t("workspace.people.editMember.success"), + errorFallback: t("workspace.people.editMember.error"), + onDone: () => closeEditModal(), + }); - useEffect(() => { - if (config) { - console.log( - "[PeopleSection] Email invites enabled:", - config.enableEmailInvites, - ); - } - }, [config]); + const toggleEnabled = useAdminMutation({ + write: (user: User) => + userManagementService.toggleUserEnabled(user.username, !user.enabled), + invalidates: ["users"], + success: t("workspace.people.toggleEnabled.success"), + errorFallback: t("workspace.people.toggleEnabled.error"), + }); - const fetchData = async () => { - try { - setLoading(true); + const deleteUser = useAdminMutation({ + write: (username: string) => userManagementService.deleteUser(username), + invalidates: ["users", "teams"], + success: t( + "workspace.people.deleteUserSuccess", + "User deleted successfully", + ), + errorFallback: t( + "workspace.people.deleteUserError", + "Failed to delete user", + ), + }); - if (loginEnabled) { - const [adminData, teamsData] = await Promise.all([ - userManagementService.getUsers(), - teamService.getTeams(), - ]); + const unlockUser = useAdminMutation({ + write: (username: string) => userManagementService.unlockUser(username), + invalidates: ["users"], + success: t( + "workspace.people.unlockUserSuccess", + "User account unlocked successfully", + ), + errorFallback: t( + "workspace.people.unlockUserError", + "Failed to unlock user account", + ), + }); - // Enrich users with session data - const enrichedUsers = adminData.users.map((user) => ({ - ...user, - isActive: adminData.userSessions[user.username] || false, - lastRequest: adminData.userLastRequest[user.username] || undefined, - mfaEnabled: - ( - adminData.userSettings?.[user.username] as - | Record - | undefined - )?.mfaEnabled === "true", - })); + const disableMfa = useAdminMutation({ + write: (username: string) => + userManagementService.disableMfaByAdmin(username), + invalidates: ["users"], + success: t( + "workspace.people.mfa.adminDisableSuccess", + "MFA disabled successfully for user", + ), + errorFallback: t( + "workspace.people.mfa.adminDisableError", + "Failed to disable MFA for user", + ), + }); - setUsers(enrichedUsers); - setTeams(teamsData); - - // Store license information - setLicenseInfo({ - maxAllowedUsers: adminData.maxAllowedUsers, - availableSlots: adminData.availableSlots, - grandfatheredUserCount: adminData.grandfatheredUserCount, - licenseMaxUsers: adminData.licenseMaxUsers, - premiumEnabled: adminData.premiumEnabled, - totalUsers: adminData.totalUsers, - }); - setMailEnabled(adminData.mailEnabled); - setLockedUsers(adminData.lockedUsers || []); - } else { - // Provide example data when login is disabled - const exampleUsers: User[] = [ - { - id: 1, - username: "admin", - email: "admin@example.com", - enabled: true, - roleName: "ROLE_ADMIN", - rolesAsString: "ROLE_ADMIN", - authenticationType: "password", - isActive: true, - lastRequest: Date.now(), - team: { id: 1, name: "Engineering" }, - }, - { - id: 2, - username: "john.doe", - email: "john.doe@example.com", - enabled: true, - roleName: "ROLE_USER", - rolesAsString: "ROLE_USER", - authenticationType: "password", - isActive: false, - lastRequest: Date.now() - 86400000, - team: { id: 1, name: "Engineering" }, - }, - { - id: 3, - username: "jane.smith", - email: "jane.smith@example.com", - enabled: true, - roleName: "ROLE_USER", - rolesAsString: "ROLE_USER", - authenticationType: "oauth", - isActive: true, - lastRequest: Date.now(), - team: { id: 2, name: "Marketing" }, - }, - { - id: 4, - username: "bob.wilson", - email: "bob.wilson@example.com", - enabled: false, - roleName: "ROLE_USER", - rolesAsString: "ROLE_USER", - authenticationType: "password", - isActive: false, - lastRequest: Date.now() - 604800000, - team: undefined, - }, - ]; - - const exampleTeams: Team[] = [ - { id: 1, name: "Engineering", userCount: 3 }, - { id: 2, name: "Marketing", userCount: 2 }, - ]; - - setUsers(exampleUsers); - setTeams(exampleTeams); - setMailEnabled(false); - setLockedUsers([]); - - // Example license information - setLicenseInfo({ - maxAllowedUsers: 10, - availableSlots: 6, - grandfatheredUserCount: 0, - licenseMaxUsers: 5, - premiumEnabled: true, - totalUsers: 4, - }); - } - } catch (error) { - console.error("[PeopleSection] Failed to fetch people data:", error); - alert({ alertType: "error", title: "Failed to load people data" }); - } finally { - setLoading(false); - } - }; - - const handleUpdateUserRole = async () => { + const handleUpdateUserRole = () => { if (!selectedUser) return; - - try { - setProcessing(true); - await userManagementService.updateUserRole({ - username: selectedUser.username, - role: editForm.role, - teamId: editForm.teamId, - }); - alert({ - alertType: "success", - title: t("workspace.people.editMember.success"), - }); - closeEditModal(); - fetchData(); - } catch (error: unknown) { - console.error("[PeopleSection] Failed to update user:", error); - const errorMessage = isAxiosError(error) - ? error.response?.data?.message || - error.response?.data?.error || - error.message - : (error instanceof Error ? error.message : undefined) || - t("workspace.people.editMember.error"); - alert({ alertType: "error", title: errorMessage }); - } finally { - setProcessing(false); - } + updateUserRole.mutate({ + username: selectedUser.username, + role: editForm.role, + teamId: editForm.teamId, + }); }; - const handleToggleEnabled = async (user: User) => { - try { - await userManagementService.toggleUserEnabled( - user.username, - !user.enabled, - ); - alert({ - alertType: "success", - title: t("workspace.people.toggleEnabled.success"), - }); - fetchData(); - } catch (error: unknown) { - console.error("[PeopleSection] Failed to toggle user status:", error); - const errorMessage = isAxiosError(error) - ? error.response?.data?.message || - error.response?.data?.error || - error.message - : (error instanceof Error ? error.message : undefined) || - t("workspace.people.toggleEnabled.error"); - alert({ alertType: "error", title: errorMessage }); - } + const handleToggleEnabled = (user: User) => { + toggleEnabled.mutate(user); }; - const handleDeleteUser = async (user: User) => { + const handleDeleteUser = (user: User) => { const confirmMessage = t( "workspace.people.confirmDelete", "Are you sure you want to delete this user? This action cannot be undone.", ); - if (!window.confirm(`${confirmMessage}\n\nUser: ${user.username}`)) { - return; - } + if ( + !window.confirm(`${confirmMessage} - try { - await userManagementService.deleteUser(user.username); - alert({ - alertType: "success", - title: t( - "workspace.people.deleteUserSuccess", - "User deleted successfully", - ), - }); - fetchData(); - } catch (error: unknown) { - console.error("[PeopleSection] Failed to delete user:", error); - const errorMessage = isAxiosError(error) - ? error.response?.data?.message || - error.response?.data?.error || - error.message - : (error instanceof Error ? error.message : undefined) || - t("workspace.people.deleteUserError", "Failed to delete user"); - alert({ alertType: "error", title: errorMessage }); - } +User: ${user.username}`) + ) + return; + deleteUser.mutate(user.username); }; - const handleUnlockUser = async (user: User) => { + const handleUnlockUser = (user: User) => { const confirmMessage = t( "workspace.people.confirmUnlock", "Are you sure you want to unlock this user account?", ); - if (!window.confirm(`${confirmMessage}\n\nUser: ${user.username}`)) { - return; - } + if ( + !window.confirm(`${confirmMessage} - try { - await userManagementService.unlockUser(user.username); - alert({ - alertType: "success", - title: t( - "workspace.people.unlockUserSuccess", - "User account unlocked successfully", - ), - }); - fetchData(); - } catch (error: unknown) { - console.error("[PeopleSection] Failed to unlock user:", error); - const errorMessage = isAxiosError(error) - ? error.response?.data?.message || - error.response?.data?.error || - error.message - : (error instanceof Error ? error.message : undefined) || - t( - "workspace.people.unlockUserError", - "Failed to unlock user account", - ); - alert({ alertType: "error", title: errorMessage }); - } +User: ${user.username}`) + ) + return; + unlockUser.mutate(user.username); }; const openEditModal = (user: User) => { @@ -549,7 +489,7 @@ export default function PeopleSection() { - + )} @@ -891,40 +831,7 @@ export default function PeopleSection() { height="1rem" /> } - onClick={async () => { - try { - await userManagementService.disableMfaByAdmin( - user.username, - ); - alert({ - alertType: "success", - title: t( - "workspace.people.mfa.adminDisableSuccess", - "MFA disabled successfully for user", - ), - }); - } catch (error: unknown) { - console.error( - "[PeopleSection] Failed to disable MFA for user:", - error, - ); - const errorMessage = isAxiosError(error) - ? error.response?.data?.message || - error.response?.data?.error || - error.message - : (error instanceof Error - ? error.message - : undefined) || - t( - "workspace.people.mfa.adminDisableError", - "Failed to disable MFA for user", - ); - alert({ - alertType: "error", - title: errorMessage, - }); - } - }} + onClick={() => disableMfa.mutate(user.username)} disabled={!loginEnabled} > {t( @@ -968,14 +875,14 @@ export default function PeopleSection() { setInviteModalOpened(false)} - onSuccess={fetchData} + onSuccess={refreshDirectory} /> @@ -1075,7 +982,7 @@ export default function PeopleSection() { /> @@ -186,7 +188,7 @@ export function FormSaveBar({ loading={saving} disabled={applying || policyEnforcing} onClick={handleDownload} - style={{ flex: 1 }} + style={{ flex: "1 1 10rem", minWidth: 0 }} > {t("viewer.formBar.download", "Download PDF")} From 8c00fffe185d027d022befac6f8d10216fab7472 Mon Sep 17 00:00:00 2001 From: brios <127139797+balazs-szucs@users.noreply.github.com> Date: Sun, 30 Aug 2026 00:04:06 +0200 Subject: [PATCH 11/37] refactor(api): replace com.fasterxml.jackson with tools.jackson (Jackson 2 to Jackson 3 namespace.) (#7444) Co-authored-by: Anthony Stirling <77850077+Frooodle@users.noreply.github.com> --- .../cluster/valkey/ValkeyJobStore.java | 14 +++--- .../storage/converter/JsonMapConverter.java | 16 +++---- .../controller/SigningSessionController.java | 4 +- .../workflow/util/WorkflowMapper.java | 4 +- .../ai/controller/AiCreateController.java | 12 ++--- .../AiCreateInternalController.java | 12 ++--- .../service/StripeUsageReportingService.java | 2 +- .../saas/legal/LegalDocumentRegistry.java | 44 +++++++++---------- .../payg/entitlement/EntitlementGuard.java | 2 +- .../api/ProcurementController.java | 2 +- .../procurement/legal/AgreementAssembler.java | 2 +- .../KeygenEnterpriseLicenseService.java | 4 +- .../service/ProcurementService.java | 6 +-- .../entitlement/EntitlementGuardTest.java | 4 +- 14 files changed, 63 insertions(+), 65 deletions(-) diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/cluster/valkey/ValkeyJobStore.java b/app/proprietary/src/main/java/stirling/software/proprietary/cluster/valkey/ValkeyJobStore.java index 8e93871859..750abea4fc 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/cluster/valkey/ValkeyJobStore.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/cluster/valkey/ValkeyJobStore.java @@ -17,16 +17,16 @@ import org.springframework.data.redis.core.ScanOptions; import org.springframework.data.redis.core.StringRedisTemplate; import org.springframework.stereotype.Component; -import com.fasterxml.jackson.core.JsonProcessingException; -import com.fasterxml.jackson.core.type.TypeReference; -import com.fasterxml.jackson.databind.ObjectMapper; - import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import stirling.software.common.cluster.JobStore; import stirling.software.common.cluster.JobStoreEntry; +import tools.jackson.core.JacksonException; +import tools.jackson.core.type.TypeReference; +import tools.jackson.databind.ObjectMapper; + /** * Valkey-backed {@link JobStore}. Each job is one hash; a reverse index maps fileId to jobId. * @@ -265,7 +265,7 @@ public class ValkeyJobStore implements JobStore { } try { return MAPPER.readValue(v.toString(), MAP_STRING); - } catch (JsonProcessingException e) { + } catch (JacksonException e) { log.warn( "JobStore {} field 'resultMeta' is not valid JSON '{}' - treating as empty", key, @@ -277,7 +277,7 @@ public class ValkeyJobStore implements JobStore { private static String writeJson(Object value) { try { return MAPPER.writeValueAsString(value); - } catch (JsonProcessingException e) { + } catch (JacksonException e) { throw new IllegalStateException("Failed to JSON-serialize JobStore field", e); } } @@ -286,7 +286,7 @@ public class ValkeyJobStore implements JobStore { try { List parsed = MAPPER.readValue(json, LIST_STRING); return parsed == null ? new ArrayList<>() : parsed; - } catch (JsonProcessingException e) { + } catch (JacksonException e) { log.warn( "JobStore {} field 'fileIds' is not valid JSON '{}' - treating as empty", key, diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/storage/converter/JsonMapConverter.java b/app/proprietary/src/main/java/stirling/software/proprietary/storage/converter/JsonMapConverter.java index 1c9f7ab765..5576ebb181 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/storage/converter/JsonMapConverter.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/storage/converter/JsonMapConverter.java @@ -3,16 +3,16 @@ package stirling.software.proprietary.storage.converter; import java.util.HashMap; import java.util.Map; -import com.fasterxml.jackson.core.JsonProcessingException; -import com.fasterxml.jackson.core.type.TypeReference; -import com.fasterxml.jackson.databind.JsonNode; -import com.fasterxml.jackson.databind.ObjectMapper; - import jakarta.persistence.AttributeConverter; import jakarta.persistence.Converter; import lombok.extern.slf4j.Slf4j; +import tools.jackson.core.JacksonException; +import tools.jackson.core.type.TypeReference; +import tools.jackson.databind.JsonNode; +import tools.jackson.databind.ObjectMapper; + /** * JPA AttributeConverter for storing Map as JSON in database columns. * @@ -33,7 +33,7 @@ public class JsonMapConverter implements AttributeConverter, try { return objectMapper.writeValueAsString(attribute); - } catch (JsonProcessingException e) { + } catch (JacksonException e) { log.error("Failed to convert map to JSON", e); throw new RuntimeException("Failed to convert map to JSON", e); } @@ -48,7 +48,7 @@ public class JsonMapConverter implements AttributeConverter, try { // Try normal parsing first return objectMapper.readValue(dbData, new TypeReference>() {}); - } catch (JsonProcessingException e) { + } catch (JacksonException e) { // Fallback: try double-parsing for legacy double-encoded data // This handles data that was stored as JSON strings instead of JSON objects log.debug("Attempting double-decode fallback for legacy metadata format"); @@ -69,7 +69,7 @@ public class JsonMapConverter implements AttributeConverter, return objectMapper.readValue( node.asText(), new TypeReference>() {}); } - } catch (JsonProcessingException e2) { + } catch (JacksonException e2) { log.error("Failed to parse metadata even with double-decode fallback", e2); } diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/workflow/controller/SigningSessionController.java b/app/proprietary/src/main/java/stirling/software/proprietary/workflow/controller/SigningSessionController.java index 4e95707217..73867776e9 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/workflow/controller/SigningSessionController.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/workflow/controller/SigningSessionController.java @@ -20,8 +20,6 @@ import org.springframework.web.bind.annotation.RestController; import org.springframework.web.multipart.MultipartFile; import org.springframework.web.server.ResponseStatusException; -import com.fasterxml.jackson.databind.ObjectMapper; - import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.tags.Tag; @@ -44,6 +42,8 @@ import stirling.software.proprietary.workflow.service.CertificateSubmissionValid import stirling.software.proprietary.workflow.service.SigningFinalizationService; import stirling.software.proprietary.workflow.service.WorkflowSessionService; +import tools.jackson.databind.ObjectMapper; + @Slf4j @RestController @RequestMapping("/api/v1/security") diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/workflow/util/WorkflowMapper.java b/app/proprietary/src/main/java/stirling/software/proprietary/workflow/util/WorkflowMapper.java index b2f53c2824..8d61ae032c 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/workflow/util/WorkflowMapper.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/workflow/util/WorkflowMapper.java @@ -4,14 +4,14 @@ import java.util.ArrayList; import java.util.List; import java.util.Map; -import com.fasterxml.jackson.databind.ObjectMapper; - import stirling.software.proprietary.workflow.dto.ParticipantResponse; import stirling.software.proprietary.workflow.dto.WetSignatureMetadata; import stirling.software.proprietary.workflow.dto.WorkflowSessionResponse; import stirling.software.proprietary.workflow.model.WorkflowParticipant; import stirling.software.proprietary.workflow.model.WorkflowSession; +import tools.jackson.databind.ObjectMapper; + /** * Utility class for mapping workflow entities to DTOs. Centralizes conversion logic for consistent * API responses. diff --git a/app/saas/src/main/java/stirling/software/saas/ai/controller/AiCreateController.java b/app/saas/src/main/java/stirling/software/saas/ai/controller/AiCreateController.java index 28bbfbdea0..77a87a27bb 100644 --- a/app/saas/src/main/java/stirling/software/saas/ai/controller/AiCreateController.java +++ b/app/saas/src/main/java/stirling/software/saas/ai/controller/AiCreateController.java @@ -26,8 +26,8 @@ import org.springframework.web.bind.annotation.RestController; import org.springframework.web.server.ResponseStatusException; import org.springframework.web.servlet.mvc.method.annotation.StreamingResponseBody; -import com.fasterxml.jackson.core.JsonProcessingException; -import com.fasterxml.jackson.databind.ObjectMapper; +import tools.jackson.core.JacksonException; +import tools.jackson.databind.ObjectMapper; import io.swagger.v3.oas.annotations.Hidden; import io.swagger.v3.oas.annotations.tags.Tag; @@ -167,7 +167,7 @@ public class AiCreateController { if (request.constraints() != null) { try { constraintsPayload = objectMapper.writeValueAsString(request.constraints()); - } catch (JsonProcessingException exc) { + } catch (JacksonException exc) { throw new ResponseStatusException( HttpStatus.BAD_REQUEST, "Invalid constraints payload", exc); } @@ -202,7 +202,7 @@ public class AiCreateController { String payload; try { payload = objectMapper.writeValueAsString(request.draftSections()); - } catch (JsonProcessingException exc) { + } catch (JacksonException exc) { throw new ResponseStatusException( HttpStatus.BAD_REQUEST, "Invalid draft sections payload", exc); } @@ -392,7 +392,7 @@ public class AiCreateController { objectMapper .getTypeFactory() .constructCollectionType(List.class, DraftSection.class)); - } catch (JsonProcessingException exc) { + } catch (JacksonException exc) { log.warn("Failed to parse draft sections payload", exc); return null; } @@ -408,7 +408,7 @@ public class AiCreateController { objectMapper .getTypeFactory() .constructMapType(Map.class, String.class, Object.class)); - } catch (JsonProcessingException exc) { + } catch (JacksonException exc) { log.warn("Failed to parse outline constraints payload", exc); return null; } diff --git a/app/saas/src/main/java/stirling/software/saas/ai/controller/AiCreateInternalController.java b/app/saas/src/main/java/stirling/software/saas/ai/controller/AiCreateInternalController.java index 60c8dc4615..04b8302e7e 100644 --- a/app/saas/src/main/java/stirling/software/saas/ai/controller/AiCreateInternalController.java +++ b/app/saas/src/main/java/stirling/software/saas/ai/controller/AiCreateInternalController.java @@ -14,8 +14,8 @@ import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RestController; import org.springframework.web.server.ResponseStatusException; -import com.fasterxml.jackson.core.JsonProcessingException; -import com.fasterxml.jackson.databind.ObjectMapper; +import tools.jackson.core.JacksonException; +import tools.jackson.databind.ObjectMapper; import io.swagger.v3.oas.annotations.Hidden; import io.swagger.v3.oas.annotations.tags.Tag; @@ -61,7 +61,7 @@ public class AiCreateInternalController { try { outlineConstraintsPayload = objectMapper.writeValueAsString(request.outlineConstraints()); - } catch (JsonProcessingException exc) { + } catch (JacksonException exc) { throw new ResponseStatusException( HttpStatus.BAD_REQUEST, "Invalid outline constraints payload", exc); } @@ -70,7 +70,7 @@ public class AiCreateInternalController { if (request.draftSections() != null) { try { draftSectionsPayload = objectMapper.writeValueAsString(request.draftSections()); - } catch (JsonProcessingException exc) { + } catch (JacksonException exc) { throw new ResponseStatusException( HttpStatus.BAD_REQUEST, "Invalid draft sections payload", exc); } @@ -136,7 +136,7 @@ public class AiCreateInternalController { .getTypeFactory() .constructCollectionType( List.class, AiCreateController.DraftSection.class)); - } catch (JsonProcessingException exc) { + } catch (JacksonException exc) { log.warn("Failed to parse draft sections payload", exc); return null; } @@ -152,7 +152,7 @@ public class AiCreateInternalController { objectMapper .getTypeFactory() .constructMapType(Map.class, String.class, Object.class)); - } catch (JsonProcessingException exc) { + } catch (JacksonException exc) { log.warn("Failed to parse outline constraints payload", exc); return null; } diff --git a/app/saas/src/main/java/stirling/software/saas/billing/service/StripeUsageReportingService.java b/app/saas/src/main/java/stirling/software/saas/billing/service/StripeUsageReportingService.java index d9445483a3..ae5d3d943a 100644 --- a/app/saas/src/main/java/stirling/software/saas/billing/service/StripeUsageReportingService.java +++ b/app/saas/src/main/java/stirling/software/saas/billing/service/StripeUsageReportingService.java @@ -12,7 +12,7 @@ import org.springframework.beans.factory.annotation.Value; import org.springframework.context.annotation.Profile; import org.springframework.stereotype.Service; -import com.fasterxml.jackson.databind.ObjectMapper; +import tools.jackson.databind.ObjectMapper; import lombok.extern.slf4j.Slf4j; diff --git a/app/saas/src/main/java/stirling/software/saas/legal/LegalDocumentRegistry.java b/app/saas/src/main/java/stirling/software/saas/legal/LegalDocumentRegistry.java index ffbe030863..48dd6a1c3b 100644 --- a/app/saas/src/main/java/stirling/software/saas/legal/LegalDocumentRegistry.java +++ b/app/saas/src/main/java/stirling/software/saas/legal/LegalDocumentRegistry.java @@ -13,8 +13,8 @@ import java.util.regex.Pattern; import org.springframework.core.io.ClassPathResource; import org.springframework.stereotype.Service; -import com.fasterxml.jackson.databind.JsonNode; -import com.fasterxml.jackson.databind.ObjectMapper; +import tools.jackson.databind.JsonNode; +import tools.jackson.databind.ObjectMapper; import jakarta.annotation.PostConstruct; @@ -56,28 +56,26 @@ public class LegalDocumentRegistry { subprocessorUrl = root.path("subprocessorUrl").asText(""); eulaUrl = root.path("eulaUrl").asText(""); JsonNode docs = root.path("documents"); - docs.fieldNames() - .forEachRemaining( - id -> { - JsonNode d = docs.get(id); - List parts = - objectMapper.convertValue( - d.path("parts"), - objectMapper - .getTypeFactory() - .constructCollectionType( - List.class, String.class)); - documents.put( + docs.forEachEntry( + (id, d) -> { + List parts = + objectMapper.convertValue( + d.path("parts"), + objectMapper + .getTypeFactory() + .constructCollectionType( + List.class, String.class)); + documents.put( + id, + new LegalDocumentMeta( id, - new LegalDocumentMeta( - id, - d.path("label").asText(id), - d.path("displayName").asText(id), - d.path("version").asText("0"), - d.path("effectiveDate").asText(""), - d.path("status").asText("draft"), - parts == null ? List.of() : parts)); - }); + d.path("label").asText(id), + d.path("displayName").asText(id), + d.path("version").asText("0"), + d.path("effectiveDate").asText(""), + d.path("status").asText("draft"), + parts == null ? List.of() : parts)); + }); log.info("[legal] loaded {} document(s) from {}", documents.size(), MANIFEST); } diff --git a/app/saas/src/main/java/stirling/software/saas/payg/entitlement/EntitlementGuard.java b/app/saas/src/main/java/stirling/software/saas/payg/entitlement/EntitlementGuard.java index ae8474fa42..441ebf1220 100644 --- a/app/saas/src/main/java/stirling/software/saas/payg/entitlement/EntitlementGuard.java +++ b/app/saas/src/main/java/stirling/software/saas/payg/entitlement/EntitlementGuard.java @@ -20,7 +20,7 @@ import org.springframework.stereotype.Component; import org.springframework.web.method.HandlerMethod; import org.springframework.web.servlet.HandlerInterceptor; -import com.fasterxml.jackson.databind.ObjectMapper; +import tools.jackson.databind.ObjectMapper; import io.micrometer.core.instrument.Counter; import io.micrometer.core.instrument.MeterRegistry; diff --git a/app/saas/src/main/java/stirling/software/saas/procurement/api/ProcurementController.java b/app/saas/src/main/java/stirling/software/saas/procurement/api/ProcurementController.java index 1c495b3afd..c08778bdbc 100644 --- a/app/saas/src/main/java/stirling/software/saas/procurement/api/ProcurementController.java +++ b/app/saas/src/main/java/stirling/software/saas/procurement/api/ProcurementController.java @@ -18,7 +18,7 @@ import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RestController; -import com.fasterxml.jackson.databind.ObjectMapper; +import tools.jackson.databind.ObjectMapper; import io.swagger.v3.oas.annotations.Hidden; diff --git a/app/saas/src/main/java/stirling/software/saas/procurement/legal/AgreementAssembler.java b/app/saas/src/main/java/stirling/software/saas/procurement/legal/AgreementAssembler.java index c641f2e596..e75f3cbb32 100644 --- a/app/saas/src/main/java/stirling/software/saas/procurement/legal/AgreementAssembler.java +++ b/app/saas/src/main/java/stirling/software/saas/procurement/legal/AgreementAssembler.java @@ -10,7 +10,7 @@ import java.util.Map; import org.springframework.stereotype.Service; -import com.fasterxml.jackson.databind.ObjectMapper; +import tools.jackson.databind.ObjectMapper; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; diff --git a/app/saas/src/main/java/stirling/software/saas/procurement/license/KeygenEnterpriseLicenseService.java b/app/saas/src/main/java/stirling/software/saas/procurement/license/KeygenEnterpriseLicenseService.java index a11ad05755..a1c09a9cb9 100644 --- a/app/saas/src/main/java/stirling/software/saas/procurement/license/KeygenEnterpriseLicenseService.java +++ b/app/saas/src/main/java/stirling/software/saas/procurement/license/KeygenEnterpriseLicenseService.java @@ -16,8 +16,8 @@ import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; import org.springframework.context.annotation.Profile; import org.springframework.stereotype.Service; -import com.fasterxml.jackson.databind.JsonNode; -import com.fasterxml.jackson.databind.ObjectMapper; +import tools.jackson.databind.JsonNode; +import tools.jackson.databind.ObjectMapper; import lombok.extern.slf4j.Slf4j; diff --git a/app/saas/src/main/java/stirling/software/saas/procurement/service/ProcurementService.java b/app/saas/src/main/java/stirling/software/saas/procurement/service/ProcurementService.java index a739e538b2..26bfb733dd 100644 --- a/app/saas/src/main/java/stirling/software/saas/procurement/service/ProcurementService.java +++ b/app/saas/src/main/java/stirling/software/saas/procurement/service/ProcurementService.java @@ -10,8 +10,8 @@ import org.springframework.context.annotation.Profile; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; -import com.fasterxml.jackson.core.JsonProcessingException; -import com.fasterxml.jackson.databind.ObjectMapper; +import tools.jackson.core.JacksonException; +import tools.jackson.databind.ObjectMapper; import lombok.extern.slf4j.Slf4j; @@ -707,7 +707,7 @@ public class ProcurementService { private String writeLineItems(QuoteBreakdown breakdown) { try { return OBJECT_MAPPER.writeValueAsString(breakdown.lineItems()); - } catch (JsonProcessingException e) { + } catch (JacksonException e) { log.warn("[procurement] failed to serialise line items", e); return "[]"; } diff --git a/app/saas/src/test/java/stirling/software/saas/payg/entitlement/EntitlementGuardTest.java b/app/saas/src/test/java/stirling/software/saas/payg/entitlement/EntitlementGuardTest.java index 0a77de3e7e..a9ca7642a6 100644 --- a/app/saas/src/test/java/stirling/software/saas/payg/entitlement/EntitlementGuardTest.java +++ b/app/saas/src/test/java/stirling/software/saas/payg/entitlement/EntitlementGuardTest.java @@ -28,8 +28,8 @@ import org.springframework.security.core.context.SecurityContextHolder; import org.springframework.security.oauth2.jwt.Jwt; import org.springframework.web.method.HandlerMethod; -import com.fasterxml.jackson.databind.JsonNode; -import com.fasterxml.jackson.databind.ObjectMapper; +import tools.jackson.databind.JsonNode; +import tools.jackson.databind.ObjectMapper; import io.micrometer.core.instrument.MeterRegistry; import io.micrometer.core.instrument.simple.SimpleMeterRegistry; From 74be5bf0ad0bbf592b8944cb2a8677f0c4055ff0 Mon Sep 17 00:00:00 2001 From: brios <127139797+balazs-szucs@users.noreply.github.com> Date: Sun, 30 Aug 2026 00:04:35 +0200 Subject: [PATCH 12/37] fix(forms): Fix checkbox export values and wide dropdown options (#7288) Co-authored-by: Anthony Stirling <77850077+Frooodle@users.noreply.github.com> --- frontend/editor/src/core/tools/formFill/FieldInput.tsx | 7 +++++-- .../editor/src/core/tools/formFill/FormFieldOverlay.tsx | 9 ++++++--- .../core/tools/formFill/providers/PdfiumFormProvider.ts | 2 +- 3 files changed, 12 insertions(+), 6 deletions(-) diff --git a/frontend/editor/src/core/tools/formFill/FieldInput.tsx b/frontend/editor/src/core/tools/formFill/FieldInput.tsx index 9662298dc4..9a8943ea47 100644 --- a/frontend/editor/src/core/tools/formFill/FieldInput.tsx +++ b/frontend/editor/src/core/tools/formFill/FieldInput.tsx @@ -68,8 +68,11 @@ function FieldInputInner({ ); case "checkbox": { - const isChecked = !!value && value !== "Off"; - const onValue = (field.widgets && field.widgets[0]?.exportValue) || "Yes"; + const exportVal = field.widgets && field.widgets[0]?.exportValue; + const isChecked = exportVal + ? value === exportVal || value === "Yes" + : !!value && value !== "Off"; + const onValue = exportVal || "Yes"; return ( Date: Sun, 30 Aug 2026 00:11:41 +0200 Subject: [PATCH 13/37] chore(crop): Remove invalid crop area message and related validation logic (#7160) Co-authored-by: Anthony Stirling <77850077+Frooodle@users.noreply.github.com> --- .../public/locales/ar-AR/translation.toml | 1 - .../public/locales/az-AZ/translation.toml | 1 - .../public/locales/bg-BG/translation.toml | 1 - .../public/locales/bo-CN/translation.toml | 1 - .../public/locales/ca-CA/translation.toml | 1 - .../public/locales/cs-CZ/translation.toml | 1 - .../public/locales/da-DK/translation.toml | 1 - .../public/locales/de-DE/translation.toml | 1 - .../public/locales/el-GR/translation.toml | 1 - .../public/locales/en-GB/translation.toml | 1 - .../public/locales/en-US/translation.toml | 1 - .../public/locales/es-ES/translation.toml | 1 - .../public/locales/eu-ES/translation.toml | 1 - .../public/locales/fa-IR/translation.toml | 1 - .../public/locales/fr-FR/translation.toml | 1 - .../public/locales/ga-IE/translation.toml | 1 - .../public/locales/hi-IN/translation.toml | 1 - .../public/locales/hr-HR/translation.toml | 1 - .../public/locales/hu-HU/translation.toml | 1 - .../public/locales/id-ID/translation.toml | 1 - .../public/locales/it-IT/translation.toml | 1 - .../public/locales/ja-JP/translation.toml | 1 - .../public/locales/ko-KR/translation.toml | 1 - .../public/locales/ml-ML/translation.toml | 1 - .../public/locales/nl-NL/translation.toml | 1 - .../public/locales/no-NB/translation.toml | 1 - .../public/locales/pl-PL/translation.toml | 1 - .../public/locales/pt-BR/translation.toml | 1 - .../public/locales/pt-PT/translation.toml | 1 - .../public/locales/ro-RO/translation.toml | 1 - .../public/locales/ru-RU/translation.toml | 1 - .../public/locales/sk-SK/translation.toml | 1 - .../public/locales/sl-SI/translation.toml | 1 - .../locales/sr-LATN-RS/translation.toml | 1 - .../public/locales/sv-SE/translation.toml | 1 - .../public/locales/th-TH/translation.toml | 1 - .../public/locales/tr-TR/translation.toml | 1 - .../public/locales/uk-UA/translation.toml | 1 - .../public/locales/vi-VN/translation.toml | 1 - .../public/locales/zh-BO/translation.toml | 1 - .../public/locales/zh-CN/translation.toml | 1 - .../public/locales/zh-TW/translation.toml | 1 - .../components/tools/crop/CropSettings.tsx | 23 +---------- .../hooks/tools/crop/useCropParameters.ts | 39 ++++++++----------- .../editor/src/core/utils/cropCoordinates.ts | 16 +++++++- 45 files changed, 32 insertions(+), 88 deletions(-) diff --git a/frontend/editor/public/locales/ar-AR/translation.toml b/frontend/editor/public/locales/ar-AR/translation.toml index 4c4d45e2b4..4180d77811 100644 --- a/frontend/editor/public/locales/ar-AR/translation.toml +++ b/frontend/editor/public/locales/ar-AR/translation.toml @@ -3526,7 +3526,6 @@ label = "إحداثي Y" [crop.error] failed = "فشل قصّ PDF" -invalidArea = "منطقة القص تتجاوز حدود PDF" [crop.preview] title = "معاينة منطقة القص" diff --git a/frontend/editor/public/locales/az-AZ/translation.toml b/frontend/editor/public/locales/az-AZ/translation.toml index f73930133e..0033be05d5 100644 --- a/frontend/editor/public/locales/az-AZ/translation.toml +++ b/frontend/editor/public/locales/az-AZ/translation.toml @@ -3526,7 +3526,6 @@ label = "Y mövqeyi" [crop.error] failed = "PDF-i kəsmək alınmadı" -invalidArea = "Kəsmə sahəsi PDF sərhədlərini aşır" [crop.preview] title = "Kəsmə sahəsinin seçimi" diff --git a/frontend/editor/public/locales/bg-BG/translation.toml b/frontend/editor/public/locales/bg-BG/translation.toml index dc6ef92e39..07cf5a73d3 100644 --- a/frontend/editor/public/locales/bg-BG/translation.toml +++ b/frontend/editor/public/locales/bg-BG/translation.toml @@ -3526,7 +3526,6 @@ label = "Y позиция" [crop.error] failed = "Неуспешно изрязване на PDF" -invalidArea = "Областта за изрязване излиза извън границите на PDF" [crop.preview] title = "Избор на област за изрязване" diff --git a/frontend/editor/public/locales/bo-CN/translation.toml b/frontend/editor/public/locales/bo-CN/translation.toml index 86d0b4f10d..21193f5f2d 100644 --- a/frontend/editor/public/locales/bo-CN/translation.toml +++ b/frontend/editor/public/locales/bo-CN/translation.toml @@ -3526,7 +3526,6 @@ label = "Yཡི་གནས་བབ།" [crop.error] failed = "སོན་བཟང་མ་འདང་བ། PDF" -invalidArea = "སོན་འདེབས་རྒྱ་ཁྱོན་དེ་PDFམཚམས་ཐིག་ལས་བརྒལ་ཡོད།" [crop.preview] title = "སོན་བཟང་ཁུལ་འདེམས་པ།" diff --git a/frontend/editor/public/locales/ca-CA/translation.toml b/frontend/editor/public/locales/ca-CA/translation.toml index 498da94436..c22244ac98 100644 --- a/frontend/editor/public/locales/ca-CA/translation.toml +++ b/frontend/editor/public/locales/ca-CA/translation.toml @@ -3526,7 +3526,6 @@ label = "Posició Y" [crop.error] failed = "No s'ha pogut retallar el PDF" -invalidArea = "L'àrea de retall s'estén més enllà dels límits del PDF" [crop.preview] title = "Selecció de l'àrea de retall" diff --git a/frontend/editor/public/locales/cs-CZ/translation.toml b/frontend/editor/public/locales/cs-CZ/translation.toml index 7b234329c7..f2161674f4 100644 --- a/frontend/editor/public/locales/cs-CZ/translation.toml +++ b/frontend/editor/public/locales/cs-CZ/translation.toml @@ -3526,7 +3526,6 @@ label = "Pozice Y" [crop.error] failed = "Oříznutí PDF se nezdařilo" -invalidArea = "Oblast ořezu přesahuje hranice PDF" [crop.preview] title = "Výběr oblasti ořezu" diff --git a/frontend/editor/public/locales/da-DK/translation.toml b/frontend/editor/public/locales/da-DK/translation.toml index f4c068da92..a0ddd7f4d9 100644 --- a/frontend/editor/public/locales/da-DK/translation.toml +++ b/frontend/editor/public/locales/da-DK/translation.toml @@ -3526,7 +3526,6 @@ label = "Y-position" [crop.error] failed = "Kunne ikke beskære PDF" -invalidArea = "Beskæringsområdet strækker sig ud over PDF'ens grænser" [crop.preview] title = "Valg af beskæringsområde" diff --git a/frontend/editor/public/locales/de-DE/translation.toml b/frontend/editor/public/locales/de-DE/translation.toml index 7c1bc79a8c..81c88d7599 100644 --- a/frontend/editor/public/locales/de-DE/translation.toml +++ b/frontend/editor/public/locales/de-DE/translation.toml @@ -3526,7 +3526,6 @@ label = "Y-Position" [crop.error] failed = "PDF zuschneiden fehlgeschlagen" -invalidArea = "Zuschneidebereich überschreitet die PDF-Grenzen" [crop.preview] title = "Zuschneidebereich-Auswahl" diff --git a/frontend/editor/public/locales/el-GR/translation.toml b/frontend/editor/public/locales/el-GR/translation.toml index 5269791aec..57ebd2eb6b 100644 --- a/frontend/editor/public/locales/el-GR/translation.toml +++ b/frontend/editor/public/locales/el-GR/translation.toml @@ -3526,7 +3526,6 @@ label = "Θέση Y" [crop.error] failed = "Αποτυχία περικοπής του PDF" -invalidArea = "Η περιοχή περικοπής εκτείνεται πέρα από τα όρια του PDF" [crop.preview] title = "Επιλογή περιοχής περικοπής" diff --git a/frontend/editor/public/locales/en-GB/translation.toml b/frontend/editor/public/locales/en-GB/translation.toml index 8fdd452928..d2b2aa38e3 100644 --- a/frontend/editor/public/locales/en-GB/translation.toml +++ b/frontend/editor/public/locales/en-GB/translation.toml @@ -3526,7 +3526,6 @@ label = "Y Position" [crop.error] failed = "Failed to crop PDF" -invalidArea = "Crop area extends beyond PDF boundaries" [crop.preview] title = "Crop Area Selection" diff --git a/frontend/editor/public/locales/en-US/translation.toml b/frontend/editor/public/locales/en-US/translation.toml index 378157818c..baee16cd02 100644 --- a/frontend/editor/public/locales/en-US/translation.toml +++ b/frontend/editor/public/locales/en-US/translation.toml @@ -3846,7 +3846,6 @@ label = "Y Position" [crop.error] failed = "Failed to crop PDF" -invalidArea = "Crop area extends beyond PDF boundaries" [crop.preview] title = "Crop Area Selection" diff --git a/frontend/editor/public/locales/es-ES/translation.toml b/frontend/editor/public/locales/es-ES/translation.toml index 73aeb53429..5fffd6eae8 100644 --- a/frontend/editor/public/locales/es-ES/translation.toml +++ b/frontend/editor/public/locales/es-ES/translation.toml @@ -3526,7 +3526,6 @@ label = "Posición Y" [crop.error] failed = "Error al recortar PDF" -invalidArea = "El área de recorte se extiende más allá de los límites del PDF" [crop.preview] title = "Selección de Área de Recorte" diff --git a/frontend/editor/public/locales/eu-ES/translation.toml b/frontend/editor/public/locales/eu-ES/translation.toml index b5a2388e67..018bfbabc5 100644 --- a/frontend/editor/public/locales/eu-ES/translation.toml +++ b/frontend/editor/public/locales/eu-ES/translation.toml @@ -3526,7 +3526,6 @@ label = "Y posizioa" [crop.error] failed = "Huts egin du PDFa mozteak" -invalidArea = "Mozketa-area PDFaren mugak baino harago doa" [crop.preview] title = "Mozketa-arearen hautapena" diff --git a/frontend/editor/public/locales/fa-IR/translation.toml b/frontend/editor/public/locales/fa-IR/translation.toml index 14a40c4694..000d039dfa 100644 --- a/frontend/editor/public/locales/fa-IR/translation.toml +++ b/frontend/editor/public/locales/fa-IR/translation.toml @@ -3526,7 +3526,6 @@ label = "موقعیت Y" [crop.error] failed = "برش PDF ناموفق بود" -invalidArea = "ناحیه برش از مرزهای PDF فراتر رفته است" [crop.preview] title = "انتخاب ناحیه برش" diff --git a/frontend/editor/public/locales/fr-FR/translation.toml b/frontend/editor/public/locales/fr-FR/translation.toml index 5550076052..e7d8a2a9ab 100644 --- a/frontend/editor/public/locales/fr-FR/translation.toml +++ b/frontend/editor/public/locales/fr-FR/translation.toml @@ -3526,7 +3526,6 @@ label = "Position Y" [crop.error] failed = "Échec du recadrage du PDF" -invalidArea = "La zone de recadrage dépasse les limites du PDF" [crop.preview] title = "Sélection de la zone de recadrage" diff --git a/frontend/editor/public/locales/ga-IE/translation.toml b/frontend/editor/public/locales/ga-IE/translation.toml index 4d3ad56168..1507ae92df 100644 --- a/frontend/editor/public/locales/ga-IE/translation.toml +++ b/frontend/editor/public/locales/ga-IE/translation.toml @@ -3526,7 +3526,6 @@ label = "Suíomh Y" [crop.error] failed = "Theip ar an PDF a bhearradh" -invalidArea = "Téann an limistéar bearrtha thar theorainneacha an PDF" [crop.preview] title = "Roghnú Limistéir Bhearrtha" diff --git a/frontend/editor/public/locales/hi-IN/translation.toml b/frontend/editor/public/locales/hi-IN/translation.toml index ae5993b387..bd769216a8 100644 --- a/frontend/editor/public/locales/hi-IN/translation.toml +++ b/frontend/editor/public/locales/hi-IN/translation.toml @@ -3526,7 +3526,6 @@ label = "Y स्थान" [crop.error] failed = "PDF क्रॉप करने में विफल" -invalidArea = "क्रॉप क्षेत्र PDF सीमाओं से बाहर जा रहा है" [crop.preview] title = "क्रॉप क्षेत्र चयन" diff --git a/frontend/editor/public/locales/hr-HR/translation.toml b/frontend/editor/public/locales/hr-HR/translation.toml index 587933825b..371c1b782c 100644 --- a/frontend/editor/public/locales/hr-HR/translation.toml +++ b/frontend/editor/public/locales/hr-HR/translation.toml @@ -3526,7 +3526,6 @@ label = "Y položaj" [crop.error] failed = "Izrezivanje PDF-a nije uspjelo" -invalidArea = "Područje izrezivanja prelazi granice PDF-a" [crop.preview] title = "Odabir područja izrezivanja" diff --git a/frontend/editor/public/locales/hu-HU/translation.toml b/frontend/editor/public/locales/hu-HU/translation.toml index 98d393c047..c8fb681564 100644 --- a/frontend/editor/public/locales/hu-HU/translation.toml +++ b/frontend/editor/public/locales/hu-HU/translation.toml @@ -3526,7 +3526,6 @@ label = "Y pozíció" [crop.error] failed = "A PDF vágása sikertelen" -invalidArea = "A vágási terület túlnyúlik a PDF határain" [crop.preview] title = "Vágási terület kiválasztása" diff --git a/frontend/editor/public/locales/id-ID/translation.toml b/frontend/editor/public/locales/id-ID/translation.toml index 15fe756aa6..83751f1dce 100644 --- a/frontend/editor/public/locales/id-ID/translation.toml +++ b/frontend/editor/public/locales/id-ID/translation.toml @@ -3526,7 +3526,6 @@ label = "Posisi Y" [crop.error] failed = "Gagal memangkas PDF" -invalidArea = "Area pangkas melampaui batas PDF" [crop.preview] title = "Pilihan Area Pangkas" diff --git a/frontend/editor/public/locales/it-IT/translation.toml b/frontend/editor/public/locales/it-IT/translation.toml index a99f00a343..592f644216 100644 --- a/frontend/editor/public/locales/it-IT/translation.toml +++ b/frontend/editor/public/locales/it-IT/translation.toml @@ -3526,7 +3526,6 @@ label = "Posizione Y" [crop.error] failed = "Impossibile ritagliare il PDF" -invalidArea = "L’area di ritaglio supera i limiti del PDF" [crop.preview] title = "Selezione area di ritaglio" diff --git a/frontend/editor/public/locales/ja-JP/translation.toml b/frontend/editor/public/locales/ja-JP/translation.toml index dcc5735dbd..a8d6730ba4 100644 --- a/frontend/editor/public/locales/ja-JP/translation.toml +++ b/frontend/editor/public/locales/ja-JP/translation.toml @@ -3526,7 +3526,6 @@ label = "Y 位置" [crop.error] failed = "PDF の切り抜きに失敗しました" -invalidArea = "切り抜き範囲が PDF の境界を超えています" [crop.preview] title = "切り抜き範囲の選択" diff --git a/frontend/editor/public/locales/ko-KR/translation.toml b/frontend/editor/public/locales/ko-KR/translation.toml index 833f710c7f..33ca27a071 100644 --- a/frontend/editor/public/locales/ko-KR/translation.toml +++ b/frontend/editor/public/locales/ko-KR/translation.toml @@ -3526,7 +3526,6 @@ label = "Y 위치" [crop.error] failed = "PDF 자르기에 실패했습니다" -invalidArea = "자르기 영역이 PDF 경계를 벗어났습니다" [crop.preview] title = "자르기 영역 선택" diff --git a/frontend/editor/public/locales/ml-ML/translation.toml b/frontend/editor/public/locales/ml-ML/translation.toml index 53e8ef302f..10235abe24 100644 --- a/frontend/editor/public/locales/ml-ML/translation.toml +++ b/frontend/editor/public/locales/ml-ML/translation.toml @@ -3526,7 +3526,6 @@ label = "Y സ്ഥാനം" [crop.error] failed = "PDF ക്രോപ്പ് ചെയ്യാൻ കഴിഞ്ഞില്ല" -invalidArea = "ക്രോപ്പ് ഏരിയ PDF അതിരുകൾക്ക് പുറത്തേക്ക് നീളുന്നു" [crop.preview] title = "ക്രോപ്പ് ഏരിയ തിരഞ്ഞെടുപ്പ്" diff --git a/frontend/editor/public/locales/nl-NL/translation.toml b/frontend/editor/public/locales/nl-NL/translation.toml index d4ada20e32..e5841842f2 100644 --- a/frontend/editor/public/locales/nl-NL/translation.toml +++ b/frontend/editor/public/locales/nl-NL/translation.toml @@ -3526,7 +3526,6 @@ label = "Y-positie" [crop.error] failed = "PDF bijsnijden mislukt" -invalidArea = "Bijsnijgebied valt buiten PDF-randen" [crop.preview] title = "Selectie bijsnijgebied" diff --git a/frontend/editor/public/locales/no-NB/translation.toml b/frontend/editor/public/locales/no-NB/translation.toml index 9402f04f22..1dd1167219 100644 --- a/frontend/editor/public/locales/no-NB/translation.toml +++ b/frontend/editor/public/locales/no-NB/translation.toml @@ -3526,7 +3526,6 @@ label = "Y-posisjon" [crop.error] failed = "Kunne ikke beskjære PDF" -invalidArea = "Beskjæringsområdet går utenfor PDF-grensene" [crop.preview] title = "Valg av beskjæringsområde" diff --git a/frontend/editor/public/locales/pl-PL/translation.toml b/frontend/editor/public/locales/pl-PL/translation.toml index 566a681f9a..c327cf2483 100644 --- a/frontend/editor/public/locales/pl-PL/translation.toml +++ b/frontend/editor/public/locales/pl-PL/translation.toml @@ -3526,7 +3526,6 @@ label = "Pozycja Y" [crop.error] failed = "Nie udało się przyciąć PDF" -invalidArea = "Obszar przycięcia wykracza poza granice PDF" [crop.preview] title = "Wybór obszaru przycięcia" diff --git a/frontend/editor/public/locales/pt-BR/translation.toml b/frontend/editor/public/locales/pt-BR/translation.toml index d89968fdfa..8455176984 100644 --- a/frontend/editor/public/locales/pt-BR/translation.toml +++ b/frontend/editor/public/locales/pt-BR/translation.toml @@ -3526,7 +3526,6 @@ label = "Posição Y" [crop.error] failed = "Falha ao recortar o PDF" -invalidArea = "A área de corte se estende além dos limites do PDF" [crop.preview] title = "Seleção da área de corte" diff --git a/frontend/editor/public/locales/pt-PT/translation.toml b/frontend/editor/public/locales/pt-PT/translation.toml index 0f9aebeff7..0b24388d75 100644 --- a/frontend/editor/public/locales/pt-PT/translation.toml +++ b/frontend/editor/public/locales/pt-PT/translation.toml @@ -3526,7 +3526,6 @@ label = "Posição Y" [crop.error] failed = "Falha ao recortar o PDF" -invalidArea = "A área de recorte excede os limites do PDF" [crop.preview] title = "Seleção da área de recorte" diff --git a/frontend/editor/public/locales/ro-RO/translation.toml b/frontend/editor/public/locales/ro-RO/translation.toml index 0f7c1555b8..2a3f4eba7d 100644 --- a/frontend/editor/public/locales/ro-RO/translation.toml +++ b/frontend/editor/public/locales/ro-RO/translation.toml @@ -3526,7 +3526,6 @@ label = "Poziția Y" [crop.error] failed = "Nu s-a putut decupa PDF-ul" -invalidArea = "Zona de decupare depășește limitele PDF-ului" [crop.preview] title = "Selecție zonă de decupare" diff --git a/frontend/editor/public/locales/ru-RU/translation.toml b/frontend/editor/public/locales/ru-RU/translation.toml index eae673f896..7d32f58a6f 100644 --- a/frontend/editor/public/locales/ru-RU/translation.toml +++ b/frontend/editor/public/locales/ru-RU/translation.toml @@ -3526,7 +3526,6 @@ label = "Положение Y" [crop.error] failed = "Не удалось обрезать PDF" -invalidArea = "Область обрезки выходит за границы PDF" [crop.preview] title = "Выбор области обрезки" diff --git a/frontend/editor/public/locales/sk-SK/translation.toml b/frontend/editor/public/locales/sk-SK/translation.toml index 1906d101d0..9383f6a866 100644 --- a/frontend/editor/public/locales/sk-SK/translation.toml +++ b/frontend/editor/public/locales/sk-SK/translation.toml @@ -3526,7 +3526,6 @@ label = "Pozícia Y" [crop.error] failed = "Nepodarilo sa orezať PDF" -invalidArea = "Oblasť orezania presahuje hranice PDF" [crop.preview] title = "Výber oblasti orezania" diff --git a/frontend/editor/public/locales/sl-SI/translation.toml b/frontend/editor/public/locales/sl-SI/translation.toml index 120606f790..c6c9cdbd54 100644 --- a/frontend/editor/public/locales/sl-SI/translation.toml +++ b/frontend/editor/public/locales/sl-SI/translation.toml @@ -3526,7 +3526,6 @@ label = "Položaj Y" [crop.error] failed = "Obrezovanje PDF-ja ni uspelo" -invalidArea = "Območje obrezovanja presega meje PDF-ja" [crop.preview] title = "Izbira območja obrezovanja" diff --git a/frontend/editor/public/locales/sr-LATN-RS/translation.toml b/frontend/editor/public/locales/sr-LATN-RS/translation.toml index 2e4cb53c7b..c639e19155 100644 --- a/frontend/editor/public/locales/sr-LATN-RS/translation.toml +++ b/frontend/editor/public/locales/sr-LATN-RS/translation.toml @@ -3526,7 +3526,6 @@ label = "Y pozicija" [crop.error] failed = "Nije uspelo isecanje PDF-a" -invalidArea = "Oblast isečka prelazi granice PDF-a" [crop.preview] title = "Izbor oblasti za isecanje" diff --git a/frontend/editor/public/locales/sv-SE/translation.toml b/frontend/editor/public/locales/sv-SE/translation.toml index bd61dbedff..ba2b56e5e0 100644 --- a/frontend/editor/public/locales/sv-SE/translation.toml +++ b/frontend/editor/public/locales/sv-SE/translation.toml @@ -3526,7 +3526,6 @@ label = "Y-position" [crop.error] failed = "Det gick inte att beskära PDF" -invalidArea = "Beskärningsområdet sträcker sig utanför PDF:ens gränser" [crop.preview] title = "Val av beskärningsområde" diff --git a/frontend/editor/public/locales/th-TH/translation.toml b/frontend/editor/public/locales/th-TH/translation.toml index 6b1c927d9f..6a294505f1 100644 --- a/frontend/editor/public/locales/th-TH/translation.toml +++ b/frontend/editor/public/locales/th-TH/translation.toml @@ -3526,7 +3526,6 @@ label = "ตำแหน่ง Y" [crop.error] failed = "ครอบตัด PDF ไม่สำเร็จ" -invalidArea = "พื้นที่ครอบตัดเกินขอบเขตของ PDF" [crop.preview] title = "การเลือกพื้นที่ครอบตัด" diff --git a/frontend/editor/public/locales/tr-TR/translation.toml b/frontend/editor/public/locales/tr-TR/translation.toml index 6ac3a86d07..ae580843e5 100644 --- a/frontend/editor/public/locales/tr-TR/translation.toml +++ b/frontend/editor/public/locales/tr-TR/translation.toml @@ -3526,7 +3526,6 @@ label = "Y Konumu" [crop.error] failed = "PDF kırpılamadı" -invalidArea = "Kırpma alanı PDF sınırlarının dışına taşıyor" [crop.preview] title = "Kırpma Alanı Seçimi" diff --git a/frontend/editor/public/locales/uk-UA/translation.toml b/frontend/editor/public/locales/uk-UA/translation.toml index d51189230b..2f9e2daeef 100644 --- a/frontend/editor/public/locales/uk-UA/translation.toml +++ b/frontend/editor/public/locales/uk-UA/translation.toml @@ -3526,7 +3526,6 @@ label = "Позиція Y" [crop.error] failed = "Не вдалося обрізати PDF" -invalidArea = "Область обрізки виходить за межі PDF" [crop.preview] title = "Вибір області обрізки" diff --git a/frontend/editor/public/locales/vi-VN/translation.toml b/frontend/editor/public/locales/vi-VN/translation.toml index a574d29833..566568dade 100644 --- a/frontend/editor/public/locales/vi-VN/translation.toml +++ b/frontend/editor/public/locales/vi-VN/translation.toml @@ -3526,7 +3526,6 @@ label = "Vị trí Y" [crop.error] failed = "Không cắt được PDF" -invalidArea = "Vùng cắt vượt quá ranh giới PDF" [crop.preview] title = "Chọn vùng cắt" diff --git a/frontend/editor/public/locales/zh-BO/translation.toml b/frontend/editor/public/locales/zh-BO/translation.toml index 895fbc5c82..7a64d7453e 100644 --- a/frontend/editor/public/locales/zh-BO/translation.toml +++ b/frontend/editor/public/locales/zh-BO/translation.toml @@ -3526,7 +3526,6 @@ label = "Y 位置" [crop.error] failed = "裁剪 PDF 失败" -invalidArea = "裁剪区域超出 PDF 边界" [crop.preview] title = "裁剪区域选择" diff --git a/frontend/editor/public/locales/zh-CN/translation.toml b/frontend/editor/public/locales/zh-CN/translation.toml index ccf73691ff..b29e3442d4 100644 --- a/frontend/editor/public/locales/zh-CN/translation.toml +++ b/frontend/editor/public/locales/zh-CN/translation.toml @@ -3526,7 +3526,6 @@ label = "Y 位置" [crop.error] failed = "裁剪 PDF 失败" -invalidArea = "裁剪区域超出 PDF 边界" [crop.preview] title = "裁剪区域选择" diff --git a/frontend/editor/public/locales/zh-TW/translation.toml b/frontend/editor/public/locales/zh-TW/translation.toml index 269d9c9d59..efb8adb8b9 100644 --- a/frontend/editor/public/locales/zh-TW/translation.toml +++ b/frontend/editor/public/locales/zh-TW/translation.toml @@ -3526,7 +3526,6 @@ label = "Y 位置" [crop.error] failed = "裁切 PDF 失敗" -invalidArea = "裁切區域超出 PDF 邊界" [crop.preview] title = "裁切區域選擇" diff --git a/frontend/editor/src/core/components/tools/crop/CropSettings.tsx b/frontend/editor/src/core/components/tools/crop/CropSettings.tsx index 82516059b0..0229613959 100644 --- a/frontend/editor/src/core/components/tools/crop/CropSettings.tsx +++ b/frontend/editor/src/core/components/tools/crop/CropSettings.tsx @@ -1,13 +1,5 @@ import { useState, useEffect } from "react"; -import { - Stack, - Text, - Box, - Group, - Center, - Alert, - Checkbox, -} from "@mantine/core"; +import { Stack, Text, Box, Group, Center, Checkbox } from "@mantine/core"; import { ActionIcon } from "@app/ui/ActionIcon"; import { useTranslation } from "react-i18next"; import RestartAltIcon from "@mui/icons-material/RestartAlt"; @@ -161,7 +153,6 @@ const CropSettings = ({ parameters, disabled = false }: CropSettingsProps) => { ); } - const isCropValid = parameters.isCropAreaValid(pdfBounds); const isFullCrop = parameters.isFullPDFCrop(pdfBounds); return ( @@ -239,18 +230,6 @@ const CropSettings = ({ parameters, disabled = false }: CropSettingsProps) => { showAutomationInfo={false} /> )} - - {/* Validation Alert - Only show when autoCrop is false */} - {!parameters.parameters.autoCrop && !isCropValid && ( - - - {t( - "crop.error.invalidArea", - "Crop area extends beyond PDF boundaries", - )} - - - )} ); }; diff --git a/frontend/editor/src/core/hooks/tools/crop/useCropParameters.ts b/frontend/editor/src/core/hooks/tools/crop/useCropParameters.ts index cc37421cdf..62e7ede14b 100644 --- a/frontend/editor/src/core/hooks/tools/crop/useCropParameters.ts +++ b/frontend/editor/src/core/hooks/tools/crop/useCropParameters.ts @@ -8,6 +8,7 @@ import { Rectangle, PDFBounds, constrainCropAreaToPDF, + createDefaultCropArea, createFullPDFCropArea, roundCropArea, isRectangle, @@ -29,6 +30,8 @@ export type CropParametersHook = BaseParametersHook & { setCropArea: (cropArea: Rectangle, pdfBounds?: PDFBounds) => void; /** Get current crop area as CropArea object */ getCropArea: () => Rectangle; + /** Reset to default inset crop area inside PDF bounds */ + resetToDefaultCropArea: (pdfBounds: PDFBounds) => void; /** Reset to full PDF dimensions */ resetToFullPDF: (pdfBounds: PDFBounds) => void; /** Check if current crop area is valid for the PDF */ @@ -76,6 +79,15 @@ export const useCropParameters = (): CropParametersHook => { [baseHook], ); + // Reset to default crop area inside PDF bounds (10% inset) + const resetToDefaultCropArea = useCallback( + (pdfBounds: PDFBounds) => { + const defaultCropArea = createDefaultCropArea(pdfBounds); + setCropArea(defaultCropArea); + }, + [setCropArea], + ); + // Reset to cover entire PDF const resetToFullPDF = useCallback( (pdfBounds: PDFBounds) => { @@ -85,31 +97,11 @@ export const useCropParameters = (): CropParametersHook => { [setCropArea], ); - // Check if current crop area is valid for the given PDF bounds + // Check if current crop area is valid (dimensions must be non-zero; out-of-bounds coordinates clamp automatically) const isCropAreaValid = useCallback( - (pdfBounds?: PDFBounds): boolean => { + (_pdfBounds?: PDFBounds): boolean => { const cropArea = getCropArea(); - - // Basic validation - if ( - cropArea.x < 0 || - cropArea.y < 0 || - cropArea.width <= 0 || - cropArea.height <= 0 - ) { - return false; - } - - // PDF bounds validation if provided - if (pdfBounds) { - const tolerance = 0.01; // Small tolerance for floating point precision - return ( - cropArea.x + cropArea.width <= pdfBounds.actualWidth + tolerance && - cropArea.y + cropArea.height <= pdfBounds.actualHeight + tolerance - ); - } - - return true; + return cropArea.width > 0 && cropArea.height > 0; }, [getCropArea], ); @@ -174,6 +166,7 @@ export const useCropParameters = (): CropParametersHook => { validateParameters: () => validateParameters(), setCropArea, getCropArea, + resetToDefaultCropArea, resetToFullPDF, isCropAreaValid, isFullPDFCrop, diff --git a/frontend/editor/src/core/utils/cropCoordinates.ts b/frontend/editor/src/core/utils/cropCoordinates.ts index 5a275c85ea..4b3bf1c600 100644 --- a/frontend/editor/src/core/utils/cropCoordinates.ts +++ b/frontend/editor/src/core/utils/cropCoordinates.ts @@ -204,7 +204,21 @@ export const isPointInThumbnail = ( }; /** - * Create a default crop area that covers the entire PDF + * Create a default crop area inside PDF bounds (10% inset from each edge, centered) + */ +export const createDefaultCropArea = (pdfBounds: PDFBounds): Rectangle => { + const insetX = pdfBounds.actualWidth * 0.1; + const insetY = pdfBounds.actualHeight * 0.1; + return { + x: Math.round(insetX * 10) / 10, + y: Math.round(insetY * 10) / 10, + width: Math.round((pdfBounds.actualWidth - insetX * 2) * 10) / 10, + height: Math.round((pdfBounds.actualHeight - insetY * 2) * 10) / 10, + }; +}; + +/** + * Create a crop area that covers the entire PDF */ export const createFullPDFCropArea = (pdfBounds: PDFBounds): Rectangle => { return { From 34694c6f5ec11b286e2c557e7eddf1813a1764f3 Mon Sep 17 00:00:00 2001 From: brios <127139797+balazs-szucs@users.noreply.github.com> Date: Sun, 30 Aug 2026 00:19:26 +0200 Subject: [PATCH 14/37] refactor(api): standardize syntax and simplify type declarations across security, workflow, and controller modules (#7127) Co-authored-by: Anthony Stirling <77850077+Frooodle@users.noreply.github.com> --- .../SPDF/pdf/parser/TabulaTableParser.java | 2 +- .../StringToMapPropertyEditor.java | 3 +- .../controller/api/EditTextController.java | 2 +- .../SPDF/controller/api/UIDataController.java | 3 +- .../api/form/FormPayloadParser.java | 9 +- .../api/misc/AddCommentsController.java | 4 +- .../proprietary/audit/AuditLevel.java | 2 +- .../cluster/valkey/ValkeyJobStore.java | 6 +- .../config/AuditConfigurationProperties.java | 2 +- .../controller/api/UsageRestController.java | 2 +- .../model/UserLicenseSettings.java | 3 +- .../security/CustomLogoutSuccessHandler.java | 32 +++--- .../configuration/SecurityConfiguration.java | 68 ++++++------- .../controller/api/AuthController.java | 23 ++--- .../controller/api/UserController.java | 16 +-- .../proprietary/security/model/Authority.java | 3 +- .../security/model/InviteToken.java | 3 +- ...tomOAuth2AuthenticationFailureHandler.java | 99 ++++++++++--------- ...mSaml2ResponseAuthenticationConverter.java | 7 +- .../service/CustomOAuth2UserService.java | 7 +- .../service/KeyPersistenceService.java | 4 +- .../security/service/UserService.java | 16 +-- .../session/SessionPersistentRegistry.java | 30 +++--- .../proprietary/storage/model/FileShare.java | 3 +- .../storage/model/FileShareAccess.java | 3 +- .../storage/model/StorageCleanupEntry.java | 3 +- .../proprietary/storage/model/StoredFile.java | 3 +- .../storage/model/StoredFileBlob.java | 3 +- .../proprietary/web/AuditWebFilter.java | 3 +- .../controller/SigningSessionController.java | 5 +- .../WorkflowParticipantController.java | 3 +- .../workflow/model/WorkflowParticipant.java | 3 +- .../workflow/model/WorkflowSession.java | 3 +- .../service/SigningFinalizationService.java | 14 +-- .../service/WorkflowSessionService.java | 31 +++--- 35 files changed, 229 insertions(+), 194 deletions(-) diff --git a/app/common/src/main/java/stirling/software/SPDF/pdf/parser/TabulaTableParser.java b/app/common/src/main/java/stirling/software/SPDF/pdf/parser/TabulaTableParser.java index b85ddbb08e..d3c516d1fe 100644 --- a/app/common/src/main/java/stirling/software/SPDF/pdf/parser/TabulaTableParser.java +++ b/app/common/src/main/java/stirling/software/SPDF/pdf/parser/TabulaTableParser.java @@ -237,7 +237,7 @@ public class TabulaTableParser implements TableParser { score -= 0.3f; } - return Math.max(0f, Math.min(1f, score)); + return Math.clamp(score, 0f, 1f); } private Bounds tableBounds(Table table) { diff --git a/app/common/src/main/java/stirling/software/common/util/propertyeditor/StringToMapPropertyEditor.java b/app/common/src/main/java/stirling/software/common/util/propertyeditor/StringToMapPropertyEditor.java index 63476d5568..7ab1013a8a 100644 --- a/app/common/src/main/java/stirling/software/common/util/propertyeditor/StringToMapPropertyEditor.java +++ b/app/common/src/main/java/stirling/software/common/util/propertyeditor/StringToMapPropertyEditor.java @@ -15,7 +15,8 @@ public class StringToMapPropertyEditor extends PropertyEditorSupport { @Override public void setAsText(String text) throws IllegalArgumentException { try { - TypeReference> typeRef = new TypeReference<>() {}; + TypeReference> typeRef = + new TypeReference>() {}; Map map = objectMapper.readValue(text, typeRef); setValue(map); } catch (Exception e) { diff --git a/app/core/src/main/java/stirling/software/SPDF/controller/api/EditTextController.java b/app/core/src/main/java/stirling/software/SPDF/controller/api/EditTextController.java index 17d1d7d8a7..b76f52144a 100644 --- a/app/core/src/main/java/stirling/software/SPDF/controller/api/EditTextController.java +++ b/app/core/src/main/java/stirling/software/SPDF/controller/api/EditTextController.java @@ -237,7 +237,7 @@ public class EditTextController { Matcher matcher = edit.pattern().matcher(joined); List spans = new ArrayList<>(); - StringBuffer interpolation = new StringBuffer(); + StringBuilder interpolation = new StringBuilder(); int previousAppendPosition = 0; while (matcher.find()) { if (matcher.start() == matcher.end()) { diff --git a/app/core/src/main/java/stirling/software/SPDF/controller/api/UIDataController.java b/app/core/src/main/java/stirling/software/SPDF/controller/api/UIDataController.java index 7f93fb3d64..b6cef0b2d6 100644 --- a/app/core/src/main/java/stirling/software/SPDF/controller/api/UIDataController.java +++ b/app/core/src/main/java/stirling/software/SPDF/controller/api/UIDataController.java @@ -95,7 +95,8 @@ public class UIDataController { try (InputStream is = resource.getInputStream()) { Map> licenseData = - objectMapper.readValue(is, new TypeReference<>() {}); + objectMapper.readValue( + is, new TypeReference>>() {}); data.setDependencies(licenseData.get("dependencies")); } catch (IOException e) { log.error("Failed to load licenses data", e); diff --git a/app/core/src/main/java/stirling/software/SPDF/controller/api/form/FormPayloadParser.java b/app/core/src/main/java/stirling/software/SPDF/controller/api/form/FormPayloadParser.java index f48f419a6d..5236706f74 100644 --- a/app/core/src/main/java/stirling/software/SPDF/controller/api/form/FormPayloadParser.java +++ b/app/core/src/main/java/stirling/software/SPDF/controller/api/form/FormPayloadParser.java @@ -25,12 +25,15 @@ final class FormPayloadParser { private static final String KEY_VALUE = "value"; private static final String KEY_DEFAULT_VALUE = "defaultValue"; - private static final TypeReference> MAP_TYPE = new TypeReference<>() {}; + private static final TypeReference> MAP_TYPE = + new TypeReference>() {}; private static final TypeReference> - MODIFY_FIELD_LIST_TYPE = new TypeReference<>() {}; + MODIFY_FIELD_LIST_TYPE = + new TypeReference>() {}; private static final TypeReference> NEW_FIELD_LIST_TYPE = new TypeReference<>() {}; - private static final TypeReference> STRING_LIST_TYPE = new TypeReference<>() {}; + private static final TypeReference> STRING_LIST_TYPE = + new TypeReference>() {}; private FormPayloadParser() {} diff --git a/app/core/src/main/java/stirling/software/SPDF/controller/api/misc/AddCommentsController.java b/app/core/src/main/java/stirling/software/SPDF/controller/api/misc/AddCommentsController.java index dc2dd22863..09b1d282e9 100644 --- a/app/core/src/main/java/stirling/software/SPDF/controller/api/misc/AddCommentsController.java +++ b/app/core/src/main/java/stirling/software/SPDF/controller/api/misc/AddCommentsController.java @@ -96,7 +96,9 @@ public class AddCommentsController { List dtos; try { - dtos = objectMapper.readValue(commentsJson, new TypeReference<>() {}); + dtos = + objectMapper.readValue( + commentsJson, new TypeReference>() {}); } catch (JacksonException e) { throw new ResponseStatusException( HttpStatus.BAD_REQUEST, "comments must be a JSON array of CommentSpec objects"); diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/audit/AuditLevel.java b/app/proprietary/src/main/java/stirling/software/proprietary/audit/AuditLevel.java index 59adc2af80..c2b0e53eb7 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/audit/AuditLevel.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/audit/AuditLevel.java @@ -59,7 +59,7 @@ public enum AuditLevel { */ public static AuditLevel fromInt(int level) { // Ensure level is within valid bounds - int boundedLevel = Math.min(Math.max(level, 0), 3); + int boundedLevel = Math.clamp(level, 0, 3); for (AuditLevel auditLevel : values()) { if (auditLevel.level == boundedLevel) { diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/cluster/valkey/ValkeyJobStore.java b/app/proprietary/src/main/java/stirling/software/proprietary/cluster/valkey/ValkeyJobStore.java index 750abea4fc..f03992ed4d 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/cluster/valkey/ValkeyJobStore.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/cluster/valkey/ValkeyJobStore.java @@ -44,8 +44,10 @@ public class ValkeyJobStore implements JobStore { private static final String FILE_INDEX_PREFIX = "stirling:file2job:"; private static final ObjectMapper MAPPER = new ObjectMapper(); - private static final TypeReference> LIST_STRING = new TypeReference<>() {}; - private static final TypeReference> MAP_STRING = new TypeReference<>() {}; + private static final TypeReference> LIST_STRING = + new TypeReference>() {}; + private static final TypeReference> MAP_STRING = + new TypeReference>() {}; private final StringRedisTemplate template; diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/config/AuditConfigurationProperties.java b/app/proprietary/src/main/java/stirling/software/proprietary/config/AuditConfigurationProperties.java index 366d91b11c..ac6c25ac5e 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/config/AuditConfigurationProperties.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/config/AuditConfigurationProperties.java @@ -35,7 +35,7 @@ public class AuditConfigurationProperties { // Ensure level is within valid bounds (0-3) int configLevel = auditConfig.getLevel(); - this.level = Math.min(Math.max(configLevel, 0), 3); + this.level = Math.clamp(configLevel, 0, 3); // Retention days (0 means infinite) this.retentionDays = auditConfig.getRetentionDays(); diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/controller/api/UsageRestController.java b/app/proprietary/src/main/java/stirling/software/proprietary/controller/api/UsageRestController.java index 1230d928cc..65bf8240a4 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/controller/api/UsageRestController.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/controller/api/UsageRestController.java @@ -48,7 +48,7 @@ public class UsageRestController { @RequestParam(value = "dataType", defaultValue = "all") String dataType, @RequestParam(value = "days", defaultValue = "30") Integer days) { - int lookbackDays = Math.max(1, Math.min(days, 365)); + int lookbackDays = Math.clamp(days, 1, 365); // Get audit events filtered by type List events = getEventsByDataType(dataType, lookbackDays); diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/model/UserLicenseSettings.java b/app/proprietary/src/main/java/stirling/software/proprietary/model/UserLicenseSettings.java index bb7f52142a..1683ad9134 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/model/UserLicenseSettings.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/model/UserLicenseSettings.java @@ -1,5 +1,6 @@ package stirling.software.proprietary.model; +import java.io.Serial; import java.io.Serializable; import jakarta.persistence.*; @@ -19,7 +20,7 @@ import lombok.*; @ToString public class UserLicenseSettings implements Serializable { - private static final long serialVersionUID = 1L; + @Serial private static final long serialVersionUID = 1L; public static final Long SINGLETON_ID = 1L; diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/security/CustomLogoutSuccessHandler.java b/app/proprietary/src/main/java/stirling/software/proprietary/security/CustomLogoutSuccessHandler.java index 4bfef06c9b..d83b684166 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/security/CustomLogoutSuccessHandler.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/security/CustomLogoutSuccessHandler.java @@ -70,21 +70,23 @@ public class CustomLogoutSuccessHandler extends SimpleUrlLogoutSuccessHandler { if (!response.isCommitted()) { if (authentication != null) { - if (authentication instanceof Saml2Authentication samlAuthentication) { - // Handle SAML2 logout redirection - getRedirect_saml2(request, response, samlAuthentication); - } else if (authentication instanceof OAuth2AuthenticationToken oAuthToken) { - // Handle OAuth2 logout redirection - getRedirect_oauth2(request, response, oAuthToken); - } else if (authentication instanceof UsernamePasswordAuthenticationToken) { - // Handle Username/Password logout - getRedirectStrategy().sendRedirect(request, response, LOGOUT_PATH); - } else { - // Handle unknown authentication types - log.error( - "Authentication class unknown: {}", - authentication.getClass().getSimpleName()); - getRedirectStrategy().sendRedirect(request, response, LOGOUT_PATH); + switch (authentication) { + case Saml2Authentication samlAuthentication -> + // Handle SAML2 logout redirection + getRedirect_saml2(request, response, samlAuthentication); + case OAuth2AuthenticationToken oAuthToken -> + // Handle OAuth2 logout redirection + getRedirect_oauth2(request, response, oAuthToken); + case UsernamePasswordAuthenticationToken usernamePasswordAuthenticationToken -> + // Handle Username/Password logout + getRedirectStrategy().sendRedirect(request, response, LOGOUT_PATH); + default -> { + // Handle unknown authentication types + log.error( + "Authentication class unknown: {}", + authentication.getClass().getSimpleName()); + getRedirectStrategy().sendRedirect(request, response, LOGOUT_PATH); + } } } else { if (jwtService != null) { diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/security/configuration/SecurityConfiguration.java b/app/proprietary/src/main/java/stirling/software/proprietary/security/configuration/SecurityConfiguration.java index 0f0c7315d9..7c5b412d33 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/security/configuration/SecurityConfiguration.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/security/configuration/SecurityConfiguration.java @@ -392,40 +392,40 @@ public class SecurityConfiguration { // Handle OAUTH2 Logins if (securityProperties.isOauth2Active()) { http.oauth2Login( - oauth2 -> { - oauth2.loginPage("/login") - .authorizationEndpoint( - authorizationEndpoint -> { - if (clientRegistrationRepository != null) { - authorizationEndpoint - .authorizationRequestResolver( - new TauriAuthorizationRequestResolver( - clientRegistrationRepository)); - } - }) - .successHandler( - new CustomOAuth2AuthenticationSuccessHandler( - loginAttemptService, - securityProperties.getOauth2(), - userService, - jwtService, - licenseSettingsService, - applicationProperties)) - .failureHandler(new CustomOAuth2AuthenticationFailureHandler()) - // Add existing Authorities from the database - .userInfoEndpoint( - userInfoEndpoint -> - userInfoEndpoint - .oidcUserService( - new CustomOAuth2UserService( - securityProperties - .getOauth2(), - userService, - loginAttemptService)) - .userAuthoritiesMapper( - oAuth2userAuthoritiesMapper)) - .permitAll(); - }); + oauth2 -> + oauth2.loginPage("/login") + .authorizationEndpoint( + authorizationEndpoint -> { + if (clientRegistrationRepository != null) { + authorizationEndpoint + .authorizationRequestResolver( + new TauriAuthorizationRequestResolver( + clientRegistrationRepository)); + } + }) + .successHandler( + new CustomOAuth2AuthenticationSuccessHandler( + loginAttemptService, + securityProperties.getOauth2(), + userService, + jwtService, + licenseSettingsService, + applicationProperties)) + .failureHandler( + new CustomOAuth2AuthenticationFailureHandler()) + // Add existing Authorities from the database + .userInfoEndpoint( + userInfoEndpoint -> + userInfoEndpoint + .oidcUserService( + new CustomOAuth2UserService( + securityProperties + .getOauth2(), + userService, + loginAttemptService)) + .userAuthoritiesMapper( + oAuth2userAuthoritiesMapper)) + .permitAll()); } // Handle SAML if (securityProperties.isSaml2Active() && runningProOrHigher) { diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/security/controller/api/AuthController.java b/app/proprietary/src/main/java/stirling/software/proprietary/security/controller/api/AuthController.java index 86a1c5fe0c..6661c86395 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/security/controller/api/AuthController.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/security/controller/api/AuthController.java @@ -703,17 +703,18 @@ public class AuthController { } private long extractEpochMillis(Object claimValue) { - if (claimValue == null) { - return -1L; - } - - if (claimValue instanceof java.util.Date date) { - return date.getTime(); - } - - if (claimValue instanceof Number number) { - long epochSeconds = number.longValue(); - return epochSeconds * 1000L; + switch (claimValue) { + case null -> { + return -1L; + } + case java.util.Date date -> { + return date.getTime(); + } + case Number number -> { + long epochSeconds = number.longValue(); + return epochSeconds * 1000L; + } + default -> {} } return -1L; diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/security/controller/api/UserController.java b/app/proprietary/src/main/java/stirling/software/proprietary/security/controller/api/UserController.java index fdacda72b2..2385eb011f 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/security/controller/api/UserController.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/security/controller/api/UserController.java @@ -760,14 +760,14 @@ public class UserController { for (Object principal : principals) { List sessionsInformation = sessionRegistry.getAllSessions(principal, false); - if (principal instanceof UserDetails detailsUser) { - userNameP = detailsUser.getUsername(); - } else if (principal instanceof OAuth2User oAuth2User) { - userNameP = oAuth2User.getName(); - } else if (principal instanceof CustomSaml2AuthenticatedPrincipal saml2User) { - userNameP = saml2User.name(); - } else if (principal instanceof String stringUser) { - userNameP = stringUser; + switch (principal) { + case null -> {} + case UserDetails detailsUser -> userNameP = detailsUser.getUsername(); + case OAuth2User oAuth2User -> userNameP = oAuth2User.getName(); + case CustomSaml2AuthenticatedPrincipal saml2User -> + userNameP = saml2User.name(); + case String stringUser -> userNameP = stringUser; + default -> {} } if (userNameP.equalsIgnoreCase(username)) { for (SessionInformation sessionInfo : sessionsInformation) { diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/security/model/Authority.java b/app/proprietary/src/main/java/stirling/software/proprietary/security/model/Authority.java index 659f7691bd..4ffea54740 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/security/model/Authority.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/security/model/Authority.java @@ -1,5 +1,6 @@ package stirling.software.proprietary.security.model; +import java.io.Serial; import java.io.Serializable; import org.springframework.security.core.GrantedAuthority; @@ -28,7 +29,7 @@ import lombok.Setter; @Setter public class Authority implements GrantedAuthority, Serializable { - private static final long serialVersionUID = 1L; + @Serial private static final long serialVersionUID = 1L; @Id @GeneratedValue(strategy = GenerationType.IDENTITY) diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/security/model/InviteToken.java b/app/proprietary/src/main/java/stirling/software/proprietary/security/model/InviteToken.java index 975220bf48..062cce058f 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/security/model/InviteToken.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/security/model/InviteToken.java @@ -1,5 +1,6 @@ package stirling.software.proprietary.security.model; +import java.io.Serial; import java.io.Serializable; import java.time.LocalDateTime; @@ -18,7 +19,7 @@ import lombok.Setter; @Setter public class InviteToken implements Serializable { - private static final long serialVersionUID = 1L; + @Serial private static final long serialVersionUID = 1L; @Id @GeneratedValue(strategy = GenerationType.IDENTITY) diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/security/oauth2/CustomOAuth2AuthenticationFailureHandler.java b/app/proprietary/src/main/java/stirling/software/proprietary/security/oauth2/CustomOAuth2AuthenticationFailureHandler.java index 784a9f0a2f..670b08c53f 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/security/oauth2/CustomOAuth2AuthenticationFailureHandler.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/security/oauth2/CustomOAuth2AuthenticationFailureHandler.java @@ -36,57 +36,62 @@ public class CustomOAuth2AuthenticationFailureHandler AuthenticationException exception) throws IOException, ServletException { - if (exception instanceof BadCredentialsException) { - log.error("BadCredentialsException", exception); - getRedirectStrategy().sendRedirect(request, response, "/login?error=badCredentials"); - return; - } - if (exception instanceof DisabledException) { - log.error("User is deactivated: ", exception); - getRedirectStrategy().sendRedirect(request, response, "/logout?userIsDisabled=true"); - return; - } - if (exception instanceof LockedException) { - log.error("Account locked: ", exception); - getRedirectStrategy().sendRedirect(request, response, "/logout?error=locked"); - return; - } - if (exception instanceof OAuth2AuthenticationException oAuth2Exception) { - OAuth2Error error = oAuth2Exception.getError(); - - String errorCode = error.getErrorCode(); - - if ("Password must not be null".equals(error.getErrorCode())) { - errorCode = "userAlreadyExistsWeb"; + switch (exception) { + case BadCredentialsException badCredentialsException -> { + log.error("BadCredentialsException", exception); + getRedirectStrategy() + .sendRedirect(request, response, "/login?error=badCredentials"); + return; } + case DisabledException disabledException -> { + log.error("User is deactivated: ", exception); + getRedirectStrategy() + .sendRedirect(request, response, "/logout?userIsDisabled=true"); + return; + } + case LockedException lockedException -> { + log.error("Account locked: ", exception); + getRedirectStrategy().sendRedirect(request, response, "/logout?error=locked"); + return; + } + case OAuth2AuthenticationException oAuth2Exception -> { + OAuth2Error error = oAuth2Exception.getError(); - log.error( - "OAuth2 Authentication error: {}", - errorCode != null ? errorCode : exception.getMessage(), - exception); - String errorValue = errorCode != null ? errorCode : "oauth2AuthenticationError"; - clearRedirectCookie(response); - boolean tauriState = TauriOAuthUtils.isTauriState(request); - String redirectUrl; - if (tauriState) { - String basePath = - TauriOAuthUtils.defaultTauriCallbackPath(request.getContextPath()); - redirectUrl = basePath; - String stateParam = request.getParameter("state"); - if (stateParam != null && !stateParam.isBlank()) { - redirectUrl = appendQueryParam(redirectUrl, "state", stateParam); - // Extract and pass nonce for CSRF validation - String nonce = TauriOAuthUtils.extractNonceFromState(stateParam); - if (nonce != null) { - redirectUrl = appendQueryParam(redirectUrl, "nonce", nonce); - } + String errorCode = error.getErrorCode(); + + if ("Password must not be null".equals(error.getErrorCode())) { + errorCode = "userAlreadyExistsWeb"; } - redirectUrl = appendQueryParam(redirectUrl, "errorOAuth", errorValue); - } else { - redirectUrl = buildFailureRedirectUrl(request, errorValue); + + log.error( + "OAuth2 Authentication error: {}", + errorCode != null ? errorCode : exception.getMessage(), + exception); + String errorValue = errorCode != null ? errorCode : "oauth2AuthenticationError"; + clearRedirectCookie(response); + boolean tauriState = TauriOAuthUtils.isTauriState(request); + String redirectUrl; + if (tauriState) { + String basePath = + TauriOAuthUtils.defaultTauriCallbackPath(request.getContextPath()); + redirectUrl = basePath; + String stateParam = request.getParameter("state"); + if (stateParam != null && !stateParam.isBlank()) { + redirectUrl = appendQueryParam(redirectUrl, "state", stateParam); + // Extract and pass nonce for CSRF validation + String nonce = TauriOAuthUtils.extractNonceFromState(stateParam); + if (nonce != null) { + redirectUrl = appendQueryParam(redirectUrl, "nonce", nonce); + } + } + redirectUrl = appendQueryParam(redirectUrl, "errorOAuth", errorValue); + } else { + redirectUrl = buildFailureRedirectUrl(request, errorValue); + } + getRedirectStrategy().sendRedirect(request, response, redirectUrl); + return; } - getRedirectStrategy().sendRedirect(request, response, redirectUrl); - return; + default -> {} } log.error("Unhandled authentication exception", exception); super.onAuthenticationFailure(request, response, exception); diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/security/saml2/CustomSaml2ResponseAuthenticationConverter.java b/app/proprietary/src/main/java/stirling/software/proprietary/security/saml2/CustomSaml2ResponseAuthenticationConverter.java index b2ce4adb68..96dcdecd03 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/security/saml2/CustomSaml2ResponseAuthenticationConverter.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/security/saml2/CustomSaml2ResponseAuthenticationConverter.java @@ -61,7 +61,12 @@ public class CustomSaml2ResponseAuthenticationConverter @Override public Saml2Authentication convert(ResponseToken responseToken) { - Assertion assertion = responseToken.getResponse().getAssertions().getFirst(); + List assertions = responseToken.getResponse().getAssertions(); + if (assertions == null || assertions.isEmpty()) { + log.error("SAML response contains no assertions"); + return null; + } + Assertion assertion = assertions.getFirst(); Map> attributes = extractAttributes(assertion); // Debug log with actual values diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/security/service/CustomOAuth2UserService.java b/app/proprietary/src/main/java/stirling/software/proprietary/security/service/CustomOAuth2UserService.java index c1057c7e36..b8054c89d9 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/security/service/CustomOAuth2UserService.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/security/service/CustomOAuth2UserService.java @@ -213,8 +213,11 @@ public class CustomOAuth2UserService implements OAuth2UserService {} + case UserDetails detailsUser -> usernameP = detailsUser.getUsername(); + case OAuth2User oAuth2User -> usernameP = oAuth2User.getName(); + case CustomSaml2AuthenticatedPrincipal saml2User -> + usernameP = saml2User.name(); + case String stringUser -> usernameP = stringUser; + default -> {} } if (usernameP.equalsIgnoreCase(username)) { sessionRegistry.expireSession(sessionsInformation.getSessionId()); diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/security/session/SessionPersistentRegistry.java b/app/proprietary/src/main/java/stirling/software/proprietary/security/session/SessionPersistentRegistry.java index e615416e59..1f3a4e84ff 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/security/session/SessionPersistentRegistry.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/security/session/SessionPersistentRegistry.java @@ -47,14 +47,13 @@ public class SessionPersistentRegistry implements SessionRegistry { List sessionInformations = new ArrayList<>(); String principalName = null; - if (principal instanceof UserDetails detailsUser) { - principalName = detailsUser.getUsername(); - } else if (principal instanceof OAuth2User oAuth2User) { - principalName = oAuth2User.getName(); - } else if (principal instanceof CustomSaml2AuthenticatedPrincipal saml2User) { - principalName = saml2User.name(); - } else if (principal instanceof String stringUser) { - principalName = stringUser; + switch (principal) { + case null -> {} + case UserDetails detailsUser -> principalName = detailsUser.getUsername(); + case OAuth2User oAuth2User -> principalName = oAuth2User.getName(); + case CustomSaml2AuthenticatedPrincipal saml2User -> principalName = saml2User.name(); + case String stringUser -> principalName = stringUser; + default -> {} } if (principalName != null) { @@ -78,14 +77,13 @@ public class SessionPersistentRegistry implements SessionRegistry { public void registerNewSession(String sessionId, Object principal) { String principalName = null; - if (principal instanceof UserDetails detailsUser) { - principalName = detailsUser.getUsername(); - } else if (principal instanceof OAuth2User oAuth2User) { - principalName = oAuth2User.getName(); - } else if (principal instanceof CustomSaml2AuthenticatedPrincipal saml2User) { - principalName = saml2User.name(); - } else if (principal instanceof String stringUser) { - principalName = stringUser; + switch (principal) { + case null -> {} + case UserDetails detailsUser -> principalName = detailsUser.getUsername(); + case OAuth2User oAuth2User -> principalName = oAuth2User.getName(); + case CustomSaml2AuthenticatedPrincipal saml2User -> principalName = saml2User.name(); + case String stringUser -> principalName = stringUser; + default -> {} } if (principalName != null) { diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/storage/model/FileShare.java b/app/proprietary/src/main/java/stirling/software/proprietary/storage/model/FileShare.java index 1b0fd86f78..6ddd0c8a86 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/storage/model/FileShare.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/storage/model/FileShare.java @@ -1,5 +1,6 @@ package stirling.software.proprietary.storage.model; +import java.io.Serial; import java.io.Serializable; import java.time.LocalDateTime; @@ -46,7 +47,7 @@ import stirling.software.proprietary.security.model.User; @Setter public class FileShare implements Serializable { - private static final long serialVersionUID = 1L; + @Serial private static final long serialVersionUID = 1L; @Id @GeneratedValue(strategy = GenerationType.IDENTITY) diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/storage/model/FileShareAccess.java b/app/proprietary/src/main/java/stirling/software/proprietary/storage/model/FileShareAccess.java index 49f75a4a4c..cb2f5d5209 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/storage/model/FileShareAccess.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/storage/model/FileShareAccess.java @@ -1,5 +1,6 @@ package stirling.software.proprietary.storage.model; +import java.io.Serial; import java.io.Serializable; import java.time.LocalDateTime; @@ -39,7 +40,7 @@ import stirling.software.proprietary.security.model.User; @Setter public class FileShareAccess implements Serializable { - private static final long serialVersionUID = 1L; + @Serial private static final long serialVersionUID = 1L; @Id @GeneratedValue(strategy = GenerationType.IDENTITY) diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/storage/model/StorageCleanupEntry.java b/app/proprietary/src/main/java/stirling/software/proprietary/storage/model/StorageCleanupEntry.java index 3158f4c041..68afe20173 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/storage/model/StorageCleanupEntry.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/storage/model/StorageCleanupEntry.java @@ -1,5 +1,6 @@ package stirling.software.proprietary.storage.model; +import java.io.Serial; import java.io.Serializable; import java.time.LocalDateTime; @@ -24,7 +25,7 @@ import lombok.Setter; @Setter public class StorageCleanupEntry implements Serializable { - private static final long serialVersionUID = 1L; + @Serial private static final long serialVersionUID = 1L; @Id @GeneratedValue(strategy = GenerationType.IDENTITY) diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/storage/model/StoredFile.java b/app/proprietary/src/main/java/stirling/software/proprietary/storage/model/StoredFile.java index db80bd1e91..1b098672b6 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/storage/model/StoredFile.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/storage/model/StoredFile.java @@ -1,5 +1,6 @@ package stirling.software.proprietary.storage.model; +import java.io.Serial; import java.io.Serializable; import java.time.LocalDateTime; import java.util.HashSet; @@ -45,7 +46,7 @@ import stirling.software.proprietary.workflow.model.WorkflowSession; @Setter public class StoredFile implements Serializable { - private static final long serialVersionUID = 1L; + @Serial private static final long serialVersionUID = 1L; @Id @GeneratedValue(strategy = GenerationType.IDENTITY) diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/storage/model/StoredFileBlob.java b/app/proprietary/src/main/java/stirling/software/proprietary/storage/model/StoredFileBlob.java index 52ef1107fc..4abcffd3e6 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/storage/model/StoredFileBlob.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/storage/model/StoredFileBlob.java @@ -1,5 +1,6 @@ package stirling.software.proprietary.storage.model; +import java.io.Serial; import java.io.Serializable; import jakarta.persistence.Column; @@ -19,7 +20,7 @@ import lombok.Setter; @Setter public class StoredFileBlob implements Serializable { - private static final long serialVersionUID = 1L; + @Serial private static final long serialVersionUID = 1L; @Id @Column(name = "storage_key", nullable = false, length = 128) diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/web/AuditWebFilter.java b/app/proprietary/src/main/java/stirling/software/proprietary/web/AuditWebFilter.java index b6f5b47f3b..70847a0702 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/web/AuditWebFilter.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/web/AuditWebFilter.java @@ -7,6 +7,7 @@ import org.slf4j.MDC; import org.springframework.core.Ordered; import org.springframework.core.annotation.Order; import org.springframework.security.core.Authentication; +import org.springframework.security.core.GrantedAuthority; import org.springframework.security.core.context.SecurityContextHolder; import org.springframework.stereotype.Component; import org.springframework.web.filter.OncePerRequestFilter; @@ -64,7 +65,7 @@ public class AuditWebFilter extends OncePerRequestFilter { if (auth != null && auth.getAuthorities() != null) { String roles = auth.getAuthorities().stream() - .map(a -> a.getAuthority()) + .map(GrantedAuthority::getAuthority) .reduce((a, b) -> a + "," + b) .orElse(""); MDC.put("userRoles", roles); diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/workflow/controller/SigningSessionController.java b/app/proprietary/src/main/java/stirling/software/proprietary/workflow/controller/SigningSessionController.java index 73867776e9..b224a09841 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/workflow/controller/SigningSessionController.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/workflow/controller/SigningSessionController.java @@ -37,6 +37,7 @@ import stirling.software.proprietary.workflow.dto.CertificateInfo; import stirling.software.proprietary.workflow.dto.CertificateValidationResponse; import stirling.software.proprietary.workflow.dto.ParticipantRequest; import stirling.software.proprietary.workflow.dto.WorkflowCreationRequest; +import stirling.software.proprietary.workflow.model.WorkflowParticipant; import stirling.software.proprietary.workflow.model.WorkflowSession; import stirling.software.proprietary.workflow.service.CertificateSubmissionValidator; import stirling.software.proprietary.workflow.service.SigningFinalizationService; @@ -259,7 +260,9 @@ public class SigningSessionController { + "database until manual cleanup.", sessionId, session.getParticipants() != null - ? session.getParticipants().stream().map(p -> p.getEmail()).toList() + ? session.getParticipants().stream() + .map(WorkflowParticipant::getEmail) + .toList() : "unknown", e); throw new ResponseStatusException( diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/workflow/controller/WorkflowParticipantController.java b/app/proprietary/src/main/java/stirling/software/proprietary/workflow/controller/WorkflowParticipantController.java index 5f903e4b56..4df0c93e1d 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/workflow/controller/WorkflowParticipantController.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/workflow/controller/WorkflowParticipantController.java @@ -5,6 +5,7 @@ import java.nio.charset.StandardCharsets; import java.time.ZoneOffset; import java.time.format.DateTimeFormatter; import java.util.HashMap; +import java.util.List; import java.util.Map; import org.springframework.http.ContentDisposition; @@ -429,7 +430,7 @@ public class WorkflowParticipantController { java.util.List> wetSigs = objectMapper.readValue( request.getWetSignaturesData(), - new TypeReference>>() {}); + new TypeReference>>() {}); if (wetSigs.size() > WetSignatureMetadata.MAX_SIGNATURES_PER_PARTICIPANT) { throw new ResponseStatusException( HttpStatus.BAD_REQUEST, "Too many wet signatures submitted"); diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/workflow/model/WorkflowParticipant.java b/app/proprietary/src/main/java/stirling/software/proprietary/workflow/model/WorkflowParticipant.java index 2e6091b963..b119565c13 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/workflow/model/WorkflowParticipant.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/workflow/model/WorkflowParticipant.java @@ -1,5 +1,6 @@ package stirling.software.proprietary.workflow.model; +import java.io.Serial; import java.io.Serializable; import java.time.LocalDateTime; import java.util.ArrayList; @@ -51,7 +52,7 @@ import stirling.software.proprietary.storage.model.ShareAccessRole; @Setter public class WorkflowParticipant implements Serializable { - private static final long serialVersionUID = 1L; + @Serial private static final long serialVersionUID = 1L; @Id @GeneratedValue(strategy = GenerationType.IDENTITY) diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/workflow/model/WorkflowSession.java b/app/proprietary/src/main/java/stirling/software/proprietary/workflow/model/WorkflowSession.java index 3fc6b53b44..7df5af710f 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/workflow/model/WorkflowSession.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/workflow/model/WorkflowSession.java @@ -1,5 +1,6 @@ package stirling.software.proprietary.workflow.model; +import java.io.Serial; import java.io.Serializable; import java.time.LocalDateTime; import java.util.ArrayList; @@ -53,7 +54,7 @@ import stirling.software.proprietary.storage.model.StoredFile; @Setter public class WorkflowSession implements Serializable { - private static final long serialVersionUID = 1L; + @Serial private static final long serialVersionUID = 1L; @Id @GeneratedValue(strategy = GenerationType.IDENTITY) diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/workflow/service/SigningFinalizationService.java b/app/proprietary/src/main/java/stirling/software/proprietary/workflow/service/SigningFinalizationService.java index e5e122df45..3fce8c69dd 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/workflow/service/SigningFinalizationService.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/workflow/service/SigningFinalizationService.java @@ -217,16 +217,13 @@ public class SigningFinalizationService { wetSignatures.size(), session.getSessionId()); - PDDocument document = pdfDocumentFactory.load(new ByteArrayInputStream(pdfBytes)); - try { + try (PDDocument document = pdfDocumentFactory.load(new ByteArrayInputStream(pdfBytes))) { for (WetSignatureMetadata wetSig : wetSignatures) { applyWetSignatureToPage(document, wetSig); } ByteArrayOutputStream baos = new ByteArrayOutputStream(); document.save(baos); return baos.toByteArray(); - } finally { - document.close(); } } @@ -242,11 +239,10 @@ public class SigningFinalizationService { } PDPage page = document.getPage(pageIndex); - PDPageContentStream contentStream = - new PDPageContentStream( - document, page, PDPageContentStream.AppendMode.APPEND, true, true); - try { + try (PDPageContentStream contentStream = + new PDPageContentStream( + document, page, PDPageContentStream.AppendMode.APPEND, true, true)) { // Use WetSignatureMetadata.extractBase64Data() to strip data URL prefix String base64Data = wetSig.extractBase64Data(); if (base64Data == null || base64Data.isBlank()) { @@ -279,8 +275,6 @@ public class SigningFinalizationService { pdfY, width, height); - } finally { - contentStream.close(); } } diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/workflow/service/WorkflowSessionService.java b/app/proprietary/src/main/java/stirling/software/proprietary/workflow/service/WorkflowSessionService.java index 4c60c60df2..a2db5deb5a 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/workflow/service/WorkflowSessionService.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/workflow/service/WorkflowSessionService.java @@ -954,21 +954,22 @@ public class WorkflowSessionService { Object pemObject = pemParser.readObject(); JcaPEMKeyConverter converter = new JcaPEMKeyConverter().setProvider("BC"); PrivateKeyInfo keyInfo; - if (pemObject instanceof PKCS8EncryptedPrivateKeyInfo encrypted) { - InputDecryptorProvider decryptor = - new JceOpenSSLPKCS8DecryptorProviderBuilder().build(password); - keyInfo = encrypted.decryptPrivateKeyInfo(decryptor); - } else if (pemObject instanceof PEMEncryptedKeyPair encryptedKeyPair) { - PEMDecryptorProvider decryptor = - new JcePEMDecryptorProviderBuilder().build(password); - keyInfo = encryptedKeyPair.decryptKeyPair(decryptor).getPrivateKeyInfo(); - } else if (pemObject instanceof PEMKeyPair keyPair) { - keyInfo = keyPair.getPrivateKeyInfo(); - } else if (pemObject instanceof PrivateKeyInfo info) { - keyInfo = info; - } else { - throw new ResponseStatusException( - HttpStatus.BAD_REQUEST, "Unsupported PEM private key format"); + switch (pemObject) { + case PKCS8EncryptedPrivateKeyInfo encrypted -> { + InputDecryptorProvider decryptor = + new JceOpenSSLPKCS8DecryptorProviderBuilder().build(password); + keyInfo = encrypted.decryptPrivateKeyInfo(decryptor); + } + case PEMEncryptedKeyPair encryptedKeyPair -> { + PEMDecryptorProvider decryptor = + new JcePEMDecryptorProviderBuilder().build(password); + keyInfo = encryptedKeyPair.decryptKeyPair(decryptor).getPrivateKeyInfo(); + } + case PEMKeyPair keyPair -> keyInfo = keyPair.getPrivateKeyInfo(); + case PrivateKeyInfo info -> keyInfo = info; + case null, default -> + throw new ResponseStatusException( + HttpStatus.BAD_REQUEST, "Unsupported PEM private key format"); } return converter.getPrivateKey(keyInfo); } From 1bb6961414f96ee00031c4de77359f78761b9dd1 Mon Sep 17 00:00:00 2001 From: "stirlingbot[bot]" <195170888+stirlingbot[bot]@users.noreply.github.com> Date: Sun, 30 Aug 2026 10:12:56 +0000 Subject: [PATCH 15/37] Update Frontend 3rd Party Licenses (#7738) Auto-generated by stirlingbot[bot] This PR updates the frontend license report based on changes to package.json dependencies. Signed-off-by: stirlingbot[bot] Co-authored-by: stirlingbot[bot] <195170888+stirlingbot[bot]@users.noreply.github.com> --- frontend/editor/src/assets/3rdPartyLicenses.json | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/frontend/editor/src/assets/3rdPartyLicenses.json b/frontend/editor/src/assets/3rdPartyLicenses.json index 1cd5f1f8bc..b2ec99d368 100644 --- a/frontend/editor/src/assets/3rdPartyLicenses.json +++ b/frontend/editor/src/assets/3rdPartyLicenses.json @@ -227,14 +227,14 @@ { "moduleName": "@mui/icons-material", "moduleUrl": "https://github.com/mui/material-ui", - "moduleVersion": "9.2.0", + "moduleVersion": "9.3.1", "moduleLicense": "MIT", "moduleLicenseUrl": "https://opensource.org/licenses/MIT" }, { "moduleName": "@mui/material", "moduleUrl": "https://github.com/mui/material-ui", - "moduleVersion": "9.2.0", + "moduleVersion": "9.3.1", "moduleLicense": "MIT", "moduleLicenseUrl": "https://opensource.org/licenses/MIT" }, @@ -297,7 +297,7 @@ { "moduleName": "@tanstack/react-virtual", "moduleUrl": "https://github.com/TanStack/virtual", - "moduleVersion": "3.13.23", + "moduleVersion": "3.14.10", "moduleLicense": "MIT", "moduleLicenseUrl": "https://opensource.org/licenses/MIT" }, From e539eb1ab12f0e4616169175709e979723152adb Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Sun, 30 Aug 2026 10:13:31 +0000 Subject: [PATCH 16/37] build(deps): bump the ubuntu group across 2 directories with 1 update (#7698) > [!WARNING] > Cooldown could not be applied because no publication date was available from the registry. > Bumps the ubuntu group with 1 update in the /docker/base directory: ubuntu. Bumps the ubuntu group with 1 update in the /docker/unoserver directory: ubuntu. Updates `ubuntu` from `561618e` to `33ceb71` Updates `ubuntu` from `561618e` to `33ceb71` Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- docker/base/Dockerfile | 8 ++++---- docker/unoserver/Dockerfile | 2 +- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/docker/base/Dockerfile b/docker/base/Dockerfile index 06e1b6e601..9205814a31 100644 --- a/docker/base/Dockerfile +++ b/docker/base/Dockerfile @@ -5,7 +5,7 @@ ARG TARGETPLATFORM # Stage 1: Build and strip Calibre -FROM ubuntu:noble@sha256:561618e2c15bf2397621dd04f96926663a3b5616c189cf7e38db7e82f5c538ea AS calibre-build +FROM ubuntu:noble@sha256:33ceb71981b602c1a7443a53469e4dba065f7503eab3078a2d7a57a2ab987517 AS calibre-build ARG TARGETPLATFORM ARG CALIBRE_VERSION=9.13.0 ARG CALIBRE_STRIP_WEBENGINE=false @@ -274,7 +274,7 @@ RUN if [ "${CALIBRE_STRIP_WEBENGINE}" = "true" ]; then \ # Stage 2: Build Ghostscript from source -FROM ubuntu:noble@sha256:561618e2c15bf2397621dd04f96926663a3b5616c189cf7e38db7e82f5c538ea AS gs-build +FROM ubuntu:noble@sha256:33ceb71981b602c1a7443a53469e4dba065f7503eab3078a2d7a57a2ab987517 AS gs-build ARG TARGETPLATFORM ARG GS_VERSION=10.07.1 @@ -298,7 +298,7 @@ RUN --mount=type=cache,target=/var/cache/apt,sharing=locked \ # Stage 3: Build PDF Tools (QPDF and ImageMagick 7) -FROM ubuntu:noble@sha256:561618e2c15bf2397621dd04f96926663a3b5616c189cf7e38db7e82f5c538ea AS pdf-tools-build +FROM ubuntu:noble@sha256:33ceb71981b602c1a7443a53469e4dba065f7503eab3078a2d7a57a2ab987517 AS pdf-tools-build ARG TARGETPLATFORM ARG QPDF_VERSION=12.4.0 ARG IM_VERSION=7.1.2-29 @@ -343,7 +343,7 @@ RUN mkdir -p /magick-export/usr/bin \ # Stage 4: Build Python venv -FROM ubuntu:noble@sha256:561618e2c15bf2397621dd04f96926663a3b5616c189cf7e38db7e82f5c538ea AS python-venv-build +FROM ubuntu:noble@sha256:33ceb71981b602c1a7443a53469e4dba065f7503eab3078a2d7a57a2ab987517 AS python-venv-build ARG TARGETPLATFORM ARG UNOSERVER_VERSION=3.7 diff --git a/docker/unoserver/Dockerfile b/docker/unoserver/Dockerfile index da5ba27371..3667c4bf5a 100644 --- a/docker/unoserver/Dockerfile +++ b/docker/unoserver/Dockerfile @@ -1,7 +1,7 @@ # Standalone unoserver image for Stirling-PDF remote UNO mode. # Pinned to unoserver 3.7 to match Stirling-PDF's client (avoids wire mismatch). -FROM ubuntu:noble@sha256:561618e2c15bf2397621dd04f96926663a3b5616c189cf7e38db7e82f5c538ea +FROM ubuntu:noble@sha256:33ceb71981b602c1a7443a53469e4dba065f7503eab3078a2d7a57a2ab987517 ARG UNOSERVER_VERSION=3.7 # ~120 MB of CJK fonts — opt-in. From 1f4cc2612df838769eb20893a6f1171a67108e48 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 31 Aug 2026 07:12:18 +0000 Subject: [PATCH 17/37] build(deps): bump the eclipse-temurin group across 3 directories with 1 update (#7740) > [!WARNING] > Cooldown could not be applied because no publication date was available from the registry. > Bumps the eclipse-temurin group with 1 update in the /docker/backend directory: eclipse-temurin. Bumps the eclipse-temurin group with 1 update in the /docker/base directory: eclipse-temurin. Bumps the eclipse-temurin group with 1 update in the /docker/embedded directory: eclipse-temurin. Updates `eclipse-temurin` from `fbcf915` to `b4c93a5` Updates `eclipse-temurin` from `fbcf915` to `b4c93a5` Updates `eclipse-temurin` from `fbcf915` to `b4c93a5` Dependabot will resolve any conflicts with this PR as long as you don't alter it yourself. You can also trigger a rebase manually by commenting `@dependabot rebase`. [//]: # (dependabot-automerge-start) [//]: # (dependabot-automerge-end) ---
Dependabot commands and options
You can trigger Dependabot actions by commenting on this PR: - `@dependabot rebase` will rebase this PR - `@dependabot recreate` will recreate this PR, overwriting any edits that have been made to it - `@dependabot show ignore conditions` will show all of the ignore conditions of the specified dependency - `@dependabot ignore major version` will close this group update PR and stop Dependabot creating any more for the specific dependency's major version (unless you unignore this specific dependency's major version or upgrade to it yourself) - `@dependabot ignore minor version` will close this group update PR and stop Dependabot creating any more for the specific dependency's minor version (unless you unignore this specific dependency's minor version or upgrade to it yourself) - `@dependabot ignore ` will close this group update PR and stop Dependabot creating any more for the specific dependency (unless you unignore this specific dependency or upgrade to it yourself) - `@dependabot unignore ` will remove all of the ignore conditions of the specified dependency - `@dependabot unignore ` will remove the ignore condition of the specified dependency and ignore conditions
Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- docker/backend/Dockerfile | 2 +- docker/base/Dockerfile | 2 +- docker/embedded/Dockerfile | 2 +- docker/embedded/Dockerfile.fat | 2 +- docker/embedded/Dockerfile.ultra-lite | 2 +- 5 files changed, 5 insertions(+), 5 deletions(-) diff --git a/docker/backend/Dockerfile b/docker/backend/Dockerfile index 39bb60bdef..ec7d7a7304 100644 --- a/docker/backend/Dockerfile +++ b/docker/backend/Dockerfile @@ -45,7 +45,7 @@ RUN JPDFIUM_PLATFORM="$([ "$TARGETARCH" = arm64 ] && echo linux-arm64 || echo li --no-daemon # Stage 2: Extract Spring Boot Layers -FROM eclipse-temurin:25-jre-noble@sha256:fbcf915c585659b30eb766ada4d6d7cfc9ec1040bf521e95bf61b10a25af73db AS jar-extract +FROM eclipse-temurin:25-jre-noble@sha256:b4c93a50fc67612798db73d68ca3b0ee4ebdd51736e59cca370e689b9797037e AS jar-extract WORKDIR /tmp COPY --from=app-build /app/app/core/build/libs/*.jar app.jar RUN java -Djarmode=tools -jar app.jar extract --layers --destination /layers diff --git a/docker/base/Dockerfile b/docker/base/Dockerfile index 9205814a31..0c73738214 100644 --- a/docker/base/Dockerfile +++ b/docker/base/Dockerfile @@ -368,7 +368,7 @@ RUN --mount=type=cache,target=/root/.cache/pip,sharing=locked \ # Final runtime image - the actual base image -FROM eclipse-temurin:25-jre-noble@sha256:fbcf915c585659b30eb766ada4d6d7cfc9ec1040bf521e95bf61b10a25af73db AS runtime +FROM eclipse-temurin:25-jre-noble@sha256:b4c93a50fc67612798db73d68ca3b0ee4ebdd51736e59cca370e689b9797037e AS runtime SHELL ["/bin/bash", "-o", "pipefail", "-c"] diff --git a/docker/embedded/Dockerfile b/docker/embedded/Dockerfile index cac30d88b1..80e163dd9a 100644 --- a/docker/embedded/Dockerfile +++ b/docker/embedded/Dockerfile @@ -61,7 +61,7 @@ RUN JPDFIUM_PLATFORM="$([ "$TARGETARCH" = arm64 ] && echo linux-arm64 || echo li --no-daemon # Stage 2: Extract Spring Boot Layers -FROM eclipse-temurin:25-jre-noble@sha256:fbcf915c585659b30eb766ada4d6d7cfc9ec1040bf521e95bf61b10a25af73db AS jar-extract +FROM eclipse-temurin:25-jre-noble@sha256:b4c93a50fc67612798db73d68ca3b0ee4ebdd51736e59cca370e689b9797037e AS jar-extract WORKDIR /tmp COPY --from=app-build /app/app/core/build/libs/*.jar app.jar RUN java -Djarmode=tools -jar app.jar extract --layers --destination /layers diff --git a/docker/embedded/Dockerfile.fat b/docker/embedded/Dockerfile.fat index 42c9a16af2..38c8bcc8f1 100644 --- a/docker/embedded/Dockerfile.fat +++ b/docker/embedded/Dockerfile.fat @@ -61,7 +61,7 @@ RUN --mount=type=cache,id=stirling-pdf-npm-cache,target=/root/.npm,sharing=locke --no-daemon # Stage 2: Extract Spring Boot Layers -FROM eclipse-temurin:25-jre-noble@sha256:fbcf915c585659b30eb766ada4d6d7cfc9ec1040bf521e95bf61b10a25af73db AS jar-extract +FROM eclipse-temurin:25-jre-noble@sha256:b4c93a50fc67612798db73d68ca3b0ee4ebdd51736e59cca370e689b9797037e AS jar-extract WORKDIR /tmp COPY --from=app-build /app/app/core/build/libs/*.jar app.jar RUN java -Djarmode=tools -jar app.jar extract --layers --destination /layers diff --git a/docker/embedded/Dockerfile.ultra-lite b/docker/embedded/Dockerfile.ultra-lite index f1389c1600..104bfd8937 100644 --- a/docker/embedded/Dockerfile.ultra-lite +++ b/docker/embedded/Dockerfile.ultra-lite @@ -62,7 +62,7 @@ RUN --mount=type=cache,id=stirling-pdf-npm-cache,target=/root/.npm,sharing=locke # Stage 2: Runtime image # glibc base (not Alpine/musl): JPDFium's PDFium natives are glibc-linked. -FROM eclipse-temurin:25-jre-noble@sha256:fbcf915c585659b30eb766ada4d6d7cfc9ec1040bf521e95bf61b10a25af73db +FROM eclipse-temurin:25-jre-noble@sha256:b4c93a50fc67612798db73d68ca3b0ee4ebdd51736e59cca370e689b9797037e ENV DEBIAN_FRONTEND=noninteractive \ LANG=C.UTF-8 \ From 3235645203cf723c7770274f2ffdfc54a48f5d0f Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 31 Aug 2026 07:13:30 +0000 Subject: [PATCH 18/37] build(deps): bump step-security/harden-runner from 2.20.0 to 2.21.0 (#7746) Bumps [step-security/harden-runner](https://github.com/step-security/harden-runner) from 2.20.0 to 2.21.0.
Release notes

Sourced from step-security/harden-runner's releases.

v2.21.0

What's Changed

  • Support for denied endpoints in block mode. This is included in the enterprise tier. Customers can deny outbound calls, for example, to public package registries.
  • Improved Support for AWS CodeBuild GitHub Actions Runners.
  • Bug fixes.

Full Changelog: https://github.com/step-security/harden-runner/compare/v2.20.1...v2.21.0

v2.20.1

What's Changed

  • AWS CodeBuild-hosted runner support
  • Implicitly allow single-labeled (internal) domains in block-mode

Full Changelog: https://github.com/step-security/harden-runner/compare/v2.20.0...v2.20.1

Commits
  • 05e3151 Merge pull request #684 from step-security/rc-42
  • 0f37afa fix: ignore denied-endpoints on non-enterprise tier
  • 93b58ee fix: resolve cache host read-first and never downgrade egress policy
  • e7399dd fix: align deny-list mode detection with agent and log when both endpoint inp...
  • c16689f test: add denied_endpoints to Configuration fixtures and cover deny-list merge
  • 40b99cf Merge pull request #682 from rohan-stepsecurity/rp/feat/codebuild-self-v2
  • fedec02 Merge branch 'rc-42' into rp/feat/codebuild-self-v2
  • 5361fb1 feat: add build artifacts
  • 286474f feat: Support Bravo agent install on CodeBuild runners
  • 051ec05 Merge pull request #683 from h0x0er/jatin/deny-list
  • Additional commits viewable in compare view

[![Dependabot compatibility score](https://dependabot-badges.githubapp.com/badges/compatibility_score?dependency-name=step-security/harden-runner&package-manager=github_actions&previous-version=2.20.0&new-version=2.21.0)](https://docs.github.com/en/github/managing-security-vulnerabilities/about-dependabot-security-updates#about-compatibility-scores) Dependabot will resolve any conflicts with this PR as long as you don't alter it yourself. You can also trigger a rebase manually by commenting `@dependabot rebase`. [//]: # (dependabot-automerge-start) [//]: # (dependabot-automerge-end) ---
Dependabot commands and options
You can trigger Dependabot actions by commenting on this PR: - `@dependabot rebase` will rebase this PR - `@dependabot recreate` will recreate this PR, overwriting any edits that have been made to it - `@dependabot show ignore conditions` will show all of the ignore conditions of the specified dependency - `@dependabot ignore this major version` will close this PR and stop Dependabot creating any more for this major version (unless you reopen the PR or upgrade to it yourself) - `@dependabot ignore this minor version` will close this PR and stop Dependabot creating any more for this minor version (unless you reopen the PR or upgrade to it yourself) - `@dependabot ignore this dependency` will close this PR and stop Dependabot creating any more for this dependency (unless you reopen the PR or upgrade to it yourself)
Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/Saas-Dev-Deploy.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/Saas-Dev-Deploy.yml b/.github/workflows/Saas-Dev-Deploy.yml index 733b92bca2..dba916b243 100644 --- a/.github/workflows/Saas-Dev-Deploy.yml +++ b/.github/workflows/Saas-Dev-Deploy.yml @@ -27,7 +27,7 @@ jobs: steps: - name: Harden Runner - uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0 + uses: step-security/harden-runner@05e31511f85b41b11d1cf0ef85d0992719546e2c # v2.21.0 with: egress-policy: audit From f6124223e433f3288245ff6c8c461f6d8b12709e Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 31 Aug 2026 07:14:20 +0000 Subject: [PATCH 19/37] build(deps): bump github/codeql-action/upload-sarif from 4.37.7 to 4.37.8 (#7750) Bumps [github/codeql-action/upload-sarif](https://github.com/github/codeql-action) from 4.37.7 to 4.37.8.
Release notes

Sourced from github/codeql-action/upload-sarif's releases.

v4.37.8

No user facing changes.

Changelog

Sourced from github/codeql-action/upload-sarif's changelog.

CodeQL Action Changelog

See the releases page for the relevant changes to the CodeQL CLI and language packs.

[UNRELEASED]

No user facing changes.

4.37.9 - 26 Aug 2026

  • Update default CodeQL bundle version to 2.26.4. #4106

4.37.8 - 21 Aug 2026

No user facing changes.

4.37.7 - 13 Aug 2026

  • Update default CodeQL bundle version to 2.26.3. #4085

4.37.6 - 04 Aug 2026

  • Changed the default filepath for the new remote file address format that was introduced in CodeQL Action 4.37.0 / 3.37.0 to .github/codeql-config.yml to align it with the suggested path that is used elsewhere. #4070

4.37.5 - 03 Aug 2026

  • Fixed a bug where a network error while streaming the download of the CodeQL bundle could terminate the init Action instead of falling back to downloading the bundle before extracting it. #4061

4.37.4 - 29 Jul 2026

  • This version of the CodeQL Action adds support for the tools input for the codeql-action/init step to be specified using a github-codeql-tools repository property. This feature will gradually be rolled out following the release of this version. Once rolled out, this allows for the CodeQL CLI version that is used in GitHub-managed workflows, such as Default Setup, to be set to a custom value. For example, customers who run into issues with rate limits when a new CodeQL CLI version is released can set the value to toolcache to always use the CodeQL CLI version that is available in the runner toolcache. For Advanced Setup workflows, the value provided for tools in the workflow definition always takes precedence unless the value of the repository property starts with !. #4037
  • Update default CodeQL bundle version to 2.26.2. #4051

4.37.3 - 22 Jul 2026

No user facing changes.

4.37.2 - 21 Jul 2026

  • The new address format for the config-file input that was introduced in CodeQL Action 4.37.0 is now enabled by default. In addition to the format described there, the remote= prefix can now be used to explicitly indicate that the input refers to a remote file. All previous input formats continue to be accepted as well. #4023
  • The CodeQL Action can now make use of configured private registries in Default Setup to retrieve CodeQL configuration files from remote repositories that require authentication. This will allow customers to store their CodeQL configuration in a single repository that can then be referenced by Default Setup workflows in other repositories. We expect to roll this and other, related changes out to everyone in July. #4007

4.37.1 - 16 Jul 2026

  • Upcoming breaking change: Add a deprecation warning for customers using CodeQL version 2.20.6 and earlier. These versions of CodeQL were discontinued on 1 July 2026 alongside GitHub Enterprise Server 3.16, and will be unsupported by the next minor release of the CodeQL Action. #3956
  • Update default CodeQL bundle version to 2.26.1. #4019

4.37.0 - 08 Jul 2026

  • Update default CodeQL bundle version to 2.26.0. #3995

... (truncated)

Commits
  • db488dd Merge pull request #4102 from github/update-v4.37.8-9ee088e13
  • 1845f5b Update changelog for v4.37.8
  • 9ee088e Merge pull request #4080 from github/henrymercer/studious-giggle
  • 1aef003 Address review feedback on overlay disk flags
  • 508b83b Merge main into overlay minimum disk feature branch
  • d97b342 Merge pull request #4098 from github/mbg/permission-error-as-configuration-error
  • 47fa622 Make EACCES a ConfigurationError
  • 45693cc Refactor ENOSPC check into isDiskConfigurationError function
  • c2fd8f5 Merge pull request #4081 from github/mario-campos/version-cache-to-disk
  • c56f48e Log unexpected conditions during caching CLI output
  • Additional commits viewable in compare view

[![Dependabot compatibility score](https://dependabot-badges.githubapp.com/badges/compatibility_score?dependency-name=github/codeql-action/upload-sarif&package-manager=github_actions&previous-version=4.37.7&new-version=4.37.8)](https://docs.github.com/en/github/managing-security-vulnerabilities/about-dependabot-security-updates#about-compatibility-scores) Dependabot will resolve any conflicts with this PR as long as you don't alter it yourself. You can also trigger a rebase manually by commenting `@dependabot rebase`. [//]: # (dependabot-automerge-start) [//]: # (dependabot-automerge-end) ---
Dependabot commands and options
You can trigger Dependabot actions by commenting on this PR: - `@dependabot rebase` will rebase this PR - `@dependabot recreate` will recreate this PR, overwriting any edits that have been made to it - `@dependabot show ignore conditions` will show all of the ignore conditions of the specified dependency - `@dependabot ignore this major version` will close this PR and stop Dependabot creating any more for this major version (unless you reopen the PR or upgrade to it yourself) - `@dependabot ignore this minor version` will close this PR and stop Dependabot creating any more for this minor version (unless you reopen the PR or upgrade to it yourself) - `@dependabot ignore this dependency` will close this PR and stop Dependabot creating any more for this dependency (unless you reopen the PR or upgrade to it yourself)
Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/scorecards.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/scorecards.yml b/.github/workflows/scorecards.yml index dbda06764b..8c7169e856 100644 --- a/.github/workflows/scorecards.yml +++ b/.github/workflows/scorecards.yml @@ -75,6 +75,6 @@ jobs: # Upload the results to GitHub's code scanning dashboard. - name: "Upload to code-scanning" - uses: github/codeql-action/upload-sarif@ff2f1c621b7f889edc0d3c761ac2e6a3f8cdb0dd # v4.37.7 + uses: github/codeql-action/upload-sarif@db488ddef3bf6cb639b32c2e9a7c0a7ea8271d28 # v4.37.8 with: sarif_file: results.sarif From b92f88361ec8837d82f2a24af036cc72d0e20ed2 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 31 Aug 2026 07:14:30 +0000 Subject: [PATCH 20/37] build(deps): bump docker/setup-buildx-action from 4.2.0 to 4.3.0 (#7711) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bumps [docker/setup-buildx-action](https://github.com/docker/setup-buildx-action) from 4.2.0 to 4.3.0.
Release notes

Sourced from docker/setup-buildx-action's releases.

v4.3.0

Full Changelog: https://github.com/docker/setup-buildx-action/compare/v4.2.0...v4.3.0

Commits
  • 37fe631 Merge pull request #595 from docker/dependabot/npm_and_yarn/docker/actions-to...
  • b5c4f91 [dependabot skip] chore: update generated content
  • 3e93b63 build(deps): bump @​docker/actions-toolkit from 0.92.0 to 0.95.0
  • e527031 Merge pull request #600 from docker/dependabot/npm_and_yarn/brace-expansion-1...
  • c68814b [dependabot skip] chore: update generated content
  • 3f891b0 build(deps): bump brace-expansion from 1.1.13 to 1.1.18
  • 787db26 Merge pull request #585 from docker/dependabot/npm_and_yarn/js-yaml-5.2.1
  • f779368 [dependabot skip] chore: update generated content
  • 7d5e604 build(deps): bump js-yaml from 5.2.0 to 5.3.0
  • 292c2fb Merge pull request #590 from docker/dependabot/github_actions/actions/setup-n...
  • Additional commits viewable in compare view

Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/PR-Auto-Deploy-V2.yml | 2 +- .github/workflows/PR-Demo-Comment-with-react.yml | 2 +- .github/workflows/Saas-Dev-Deploy.yml | 2 +- .github/workflows/docker-compose-tests.yml | 2 +- .github/workflows/push-docker-base.yml | 2 +- .github/workflows/push-docker.yml | 2 +- .github/workflows/test-build-docker.yml | 4 ++-- 7 files changed, 8 insertions(+), 8 deletions(-) diff --git a/.github/workflows/PR-Auto-Deploy-V2.yml b/.github/workflows/PR-Auto-Deploy-V2.yml index 6420712640..c27fb6b5f2 100644 --- a/.github/workflows/PR-Auto-Deploy-V2.yml +++ b/.github/workflows/PR-Auto-Deploy-V2.yml @@ -182,7 +182,7 @@ jobs: fetch-depth: 0 # Fetch full history for commit hash detection - name: Set up Docker Buildx - uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4.2.0 + uses: docker/setup-buildx-action@37fe631027851001ddb9b187196cc803df7f5f0e # v4.3.0 - name: Get version number id: versionNumber diff --git a/.github/workflows/PR-Demo-Comment-with-react.yml b/.github/workflows/PR-Demo-Comment-with-react.yml index 111dba441f..b43020ceb0 100644 --- a/.github/workflows/PR-Demo-Comment-with-react.yml +++ b/.github/workflows/PR-Demo-Comment-with-react.yml @@ -222,7 +222,7 @@ jobs: STIRLING_PDF_DESKTOP_UI: false - name: Set up Docker Buildx - uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4.2.0 + uses: docker/setup-buildx-action@37fe631027851001ddb9b187196cc803df7f5f0e # v4.3.0 - name: Login to GitHub Container Registry uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0 diff --git a/.github/workflows/Saas-Dev-Deploy.yml b/.github/workflows/Saas-Dev-Deploy.yml index dba916b243..84aa57d5f2 100644 --- a/.github/workflows/Saas-Dev-Deploy.yml +++ b/.github/workflows/Saas-Dev-Deploy.yml @@ -45,7 +45,7 @@ jobs: fetch-depth: 0 - name: Set up Docker Buildx - uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4.2.0 + uses: docker/setup-buildx-action@37fe631027851001ddb9b187196cc803df7f5f0e # v4.3.0 - name: Login to GitHub Container Registry uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0 diff --git a/.github/workflows/docker-compose-tests.yml b/.github/workflows/docker-compose-tests.yml index cf5887a205..b37babfdf6 100644 --- a/.github/workflows/docker-compose-tests.yml +++ b/.github/workflows/docker-compose-tests.yml @@ -57,7 +57,7 @@ jobs: # runtime token isn't exposed) since the docker driver can't use it. - name: Set up Docker Buildx if: inputs.docker-base-changed != 'true' - uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4.2.0 + uses: docker/setup-buildx-action@37fe631027851001ddb9b187196cc803df7f5f0e # v4.3.0 # Expose ACTIONS_RUNTIME_TOKEN / ACTIONS_RESULTS_URL for docker buildx type=gha cache backend. - name: Expose GitHub runtime for Buildx cache diff --git a/.github/workflows/push-docker-base.yml b/.github/workflows/push-docker-base.yml index 9da49ad7ae..6bfae2b300 100644 --- a/.github/workflows/push-docker-base.yml +++ b/.github/workflows/push-docker-base.yml @@ -69,7 +69,7 @@ jobs: - name: Set up Docker Buildx id: buildx - uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4.2.0 + uses: docker/setup-buildx-action@37fe631027851001ddb9b187196cc803df7f5f0e # v4.3.0 - name: Set up QEMU uses: docker/setup-qemu-action@96fe6ef7f33517b61c61be40b68a1882f3264fb8 # v4.2.0 diff --git a/.github/workflows/push-docker.yml b/.github/workflows/push-docker.yml index 844a77b489..8e8b26be91 100644 --- a/.github/workflows/push-docker.yml +++ b/.github/workflows/push-docker.yml @@ -85,7 +85,7 @@ jobs: - name: Set up Docker Buildx id: buildx - uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4.2.0 + uses: docker/setup-buildx-action@37fe631027851001ddb9b187196cc803df7f5f0e # v4.3.0 - name: Install Task uses: go-task/setup-task@01a4adf9db2d14c1de7a560f09170b6e0df736aa # v2.1.0 diff --git a/.github/workflows/test-build-docker.yml b/.github/workflows/test-build-docker.yml index 4717c9c387..e38fcb4cd6 100644 --- a/.github/workflows/test-build-docker.yml +++ b/.github/workflows/test-build-docker.yml @@ -142,7 +142,7 @@ jobs: - name: Set up Docker Buildx id: buildx - uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4.2.0 + uses: docker/setup-buildx-action@37fe631027851001ddb9b187196cc803df7f5f0e # v4.3.0 - name: Set base image and platform for this build id: build-params @@ -229,7 +229,7 @@ jobs: - name: Set up Docker Buildx id: buildx - uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4.2.0 + uses: docker/setup-buildx-action@37fe631027851001ddb9b187196cc803df7f5f0e # v4.3.0 - name: Build docker/unoserver/Dockerfile uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.0 From b1e857fd01e8e3aca55f8903ac07d9502bd25c2f Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 31 Aug 2026 10:13:47 +0100 Subject: [PATCH 21/37] build(deps): bump com.tngtech.archunit:archunit-junit5 from 1.4.2 to 1.5.0 (#7704) Signed-off-by: dependabot[bot] --- build.gradle | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/build.gradle b/build.gradle index fdc59f3680..9dbfcf9b24 100644 --- a/build.gradle +++ b/build.gradle @@ -40,7 +40,7 @@ ext { jinjavaVersion = "2.8.4" jackson2Version = "2.22.1" bucket4jVersion = "8.19.0" - archunitVersion = "1.4.2" + archunitVersion = "1.5.0" batikVersion = "1.19" jpdfiumVersion = "1.1.3" jwtVersion = "0.13.0" From 5fe7df393364f39e5e96941ce12ccf807c76563a Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 31 Aug 2026 10:14:04 +0100 Subject: [PATCH 22/37] build(deps): bump jackson2Version from 2.22.1 to 2.22.2 (#7703) Signed-off-by: dependabot[bot] --- build.gradle | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/build.gradle b/build.gradle index 9dbfcf9b24..6ee8baca47 100644 --- a/build.gradle +++ b/build.gradle @@ -38,7 +38,7 @@ ext { gsonVersion = "2.14.0" guavaVersion = "33.6.0-jre" jinjavaVersion = "2.8.4" - jackson2Version = "2.22.1" + jackson2Version = "2.22.2" bucket4jVersion = "8.19.0" archunitVersion = "1.5.0" batikVersion = "1.19" From 3aca7a26f6b1b1f37c7cd1627be0b163200351ab Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 31 Aug 2026 10:14:19 +0100 Subject: [PATCH 23/37] build(deps-dev): bump python-dotenv from 1.2.2 to 1.2.3 in /engine (#7702) Signed-off-by: dependabot[bot] --- engine/pyproject.toml | 2 +- engine/uv.lock | 8 ++++---- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/engine/pyproject.toml b/engine/pyproject.toml index 23c3bc078c..3fe7ccdb22 100644 --- a/engine/pyproject.toml +++ b/engine/pyproject.toml @@ -20,7 +20,7 @@ engine = [ # No `voyageai` extra either; stirling.documents.voyage speaks its API directly. "pydantic-ai-slim[anthropic,openai]>=1.107.2,<2.0.0", "pydantic-settings>=2.15.0", - "python-dotenv>=1.2.2", + "python-dotenv>=1.2.3", "sqlite-vec>=0.1.9", "uvicorn>=0.52.3", ] diff --git a/engine/uv.lock b/engine/uv.lock index a3286b100d..4681861fe3 100644 --- a/engine/uv.lock +++ b/engine/uv.lock @@ -479,7 +479,7 @@ engine = [ { name = "pydantic", specifier = ">=2.13.4" }, { name = "pydantic-ai-slim", extras = ["anthropic", "openai"], specifier = ">=1.107.2,<2.0.0" }, { name = "pydantic-settings", specifier = ">=2.15.0" }, - { name = "python-dotenv", specifier = ">=1.2.2" }, + { name = "python-dotenv", specifier = ">=1.2.3" }, { name = "sqlite-vec", specifier = ">=0.1.9" }, { name = "uvicorn", specifier = ">=0.52.3" }, ] @@ -1262,11 +1262,11 @@ wheels = [ [[package]] name = "python-dotenv" -version = "1.2.2" +version = "1.2.3" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/82/ed/0301aeeac3e5353ef3d94b6ec08bbcabd04a72018415dcb29e588514bba8/python_dotenv-1.2.2.tar.gz", hash = "sha256:2c371a91fbd7ba082c2c1dc1f8bf89ca22564a087c2c287cd9b662adde799cf3", size = 50135, upload-time = "2026-03-01T16:00:26.196Z" } +sdist = { url = "https://files.pythonhosted.org/packages/6a/53/ed9d74092561d4b01a2ef1349d52cdbc135e526c245f366b089cfca6de49/python_dotenv-1.2.3.tar.gz", hash = "sha256:a20a594dabeaa385725aa239d5244871c143ecb356add8a20fcf23773a6c3a35", size = 58945, upload-time = "2026-08-16T16:54:54.067Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/0b/d7/1959b9648791274998a9c3526f6d0ec8fd2233e4d4acce81bbae76b44b2a/python_dotenv-1.2.2-py3-none-any.whl", hash = "sha256:1d8214789a24de455a8b8bd8ae6fe3c6b69a5e3d64aa8a8e5d68e694bbcb285a", size = 22101, upload-time = "2026-03-01T16:00:25.09Z" }, + { url = "https://files.pythonhosted.org/packages/0d/17/c5c6b53ddc18f297992099b3d9ec16c855c0ccc83263a21fe4d1c625ec6c/python_dotenv-1.2.3-py3-none-any.whl", hash = "sha256:904552145e8bfed22162c09dab1c2b9b54fefa7b23ba780f4f26ca0316b0f0d9", size = 22780, upload-time = "2026-08-16T16:54:52.473Z" }, ] [[package]] From 54e839ae65e7968e51c42214a790c054bf67628d Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 31 Aug 2026 10:14:31 +0100 Subject: [PATCH 24/37] build(deps-dev): bump reportlab from 5.0.0 to 5.0.1 in /engine (#7699) Signed-off-by: dependabot[bot] --- engine/pyproject.toml | 2 +- engine/uv.lock | 8 ++++---- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/engine/pyproject.toml b/engine/pyproject.toml index 3fe7ccdb22..179fe8c1d1 100644 --- a/engine/pyproject.toml +++ b/engine/pyproject.toml @@ -42,7 +42,7 @@ cucumber = [ "pillow>=12.3.0", "pypdf[crypto]>=6.15.0", "qrcode[pil]>=8.2", - "reportlab>=5.0.0", + "reportlab>=5.0.1", "requests>=2.34.2", ] # Shared Python utilities used by repository scripts and CI workflows. diff --git a/engine/uv.lock b/engine/uv.lock index 4681861fe3..8c76b57b3b 100644 --- a/engine/uv.lock +++ b/engine/uv.lock @@ -466,7 +466,7 @@ cucumber = [ { name = "pillow", specifier = ">=12.3.0" }, { name = "pypdf", extras = ["crypto"], specifier = ">=6.15.0" }, { name = "qrcode", extras = ["pil"], specifier = ">=8.2" }, - { name = "reportlab", specifier = ">=5.0.0" }, + { name = "reportlab", specifier = ">=5.0.1" }, { name = "requests", specifier = ">=2.34.2" }, ] engine = [ @@ -1373,15 +1373,15 @@ wheels = [ [[package]] name = "reportlab" -version = "5.0.0" +version = "5.0.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "charset-normalizer" }, { name = "pillow" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/41/d6/4b7b0cf56880eb96533e607967be6a939e344675601e033d113a0bfa1f4e/reportlab-5.0.0.tar.gz", hash = "sha256:e4494a0c6623ae213bb856fba523171b2b54a7bf629fda02d5e525a7b899a784", size = 3701928, upload-time = "2026-06-18T11:34:31.145Z" } +sdist = { url = "https://files.pythonhosted.org/packages/4a/51/dbe28534ae12c852f61be91f039f343305fd1f34f1c66b8de75afae7a525/reportlab-5.0.1.tar.gz", hash = "sha256:ebd13154be1c8515e665de70bd2d303ae9ddc3ef47e44afd5116441ca0283a26", size = 3945711, upload-time = "2026-08-20T13:48:16.461Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/a3/07/70085c17a369605f15e301d10ab902115019b1126c7253d964afc230c7d6/reportlab-5.0.0-py3-none-any.whl", hash = "sha256:9d5a3affa84919e1111ede580031266a570e93b1ce388219621347965ff1d93c", size = 1956710, upload-time = "2026-06-18T11:34:29.07Z" }, + { url = "https://files.pythonhosted.org/packages/db/cb/dacbc268cb68d0428ea2cbd85266195a9ab3e677449589ddae59bd7542ac/reportlab-5.0.1-py3-none-any.whl", hash = "sha256:1c36e6bb0e71780c72331eba60da7f602e8d4389a8723825af71342e49d791e8", size = 1957258, upload-time = "2026-08-20T13:48:14.026Z" }, ] [[package]] From d93049db9f4f01981a5fc922d3fb2cff64ddc7db Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 31 Aug 2026 10:14:56 +0100 Subject: [PATCH 25/37] build(deps): bump log from 0.4.33 to 0.4.34 in /frontend/editor/src-tauri (#7747) Signed-off-by: dependabot[bot] --- frontend/editor/src-tauri/Cargo.lock | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/frontend/editor/src-tauri/Cargo.lock b/frontend/editor/src-tauri/Cargo.lock index fe8352f5a7..a875adc740 100644 --- a/frontend/editor/src-tauri/Cargo.lock +++ b/frontend/editor/src-tauri/Cargo.lock @@ -2429,9 +2429,9 @@ dependencies = [ [[package]] name = "log" -version = "0.4.33" +version = "0.4.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad" +checksum = "f9f8bd3e56ce4dfc153cf470fffbfa98c7620958b312ca5c3a4b8d5181fd13c6" dependencies = [ "value-bag", ] From 96207a7304078e3323e419581d30bc0b667795c9 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 31 Aug 2026 10:15:11 +0100 Subject: [PATCH 26/37] build(deps): bump @tanstack/react-query from 5.101.4 to 5.102.0 in /frontend in the tanstack group across 1 directory (#7749) Signed-off-by: dependabot[bot] --- frontend/package-lock.json | 16 ++++++++-------- frontend/package.json | 2 +- 2 files changed, 9 insertions(+), 9 deletions(-) diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 198751f05d..85d011f49b 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -49,7 +49,7 @@ "@stripe/stripe-js": "^9.10.0", "@supabase/supabase-js": "^2.47.13", "@tailwindcss/postcss": "^4.1.13", - "@tanstack/react-query": "^5.101.4", + "@tanstack/react-query": "^5.102.0", "@tanstack/react-table": "^9.1.2", "@tanstack/react-virtual": "^3.14.10", "@tauri-apps/api": "^2.10.1", @@ -5219,9 +5219,9 @@ } }, "node_modules/@tanstack/query-core": { - "version": "5.101.4", - "resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.101.4.tgz", - "integrity": "sha512-gNwcvOJcRbLWPOLG/2OBm+zM+Yv+MKsXKEOWC57USuZDEsI71hEErQsiEGx5wX9rzWWkfwM0fVSPoiIFSsxfiw==", + "version": "5.102.0", + "resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.102.0.tgz", + "integrity": "sha512-tvBzr11Q7StuMCEsIJdqX8TAWt6WZIzfw/yrSAjObZDerwTTPeCxeLXN2R8ZSn4ZxFpQV819Xn8QmoO+vtnDvw==", "license": "MIT", "funding": { "type": "github", @@ -5229,12 +5229,12 @@ } }, "node_modules/@tanstack/react-query": { - "version": "5.101.4", - "resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.101.4.tgz", - "integrity": "sha512-yRg2pfOCxIs4ZJW3XYYHU/WgtD04FHSnfHlpRT7h7pR77hwkdRG4wxbKe4aq6P0RvXUTBSQpQeadS1SUYUe+KA==", + "version": "5.102.0", + "resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.102.0.tgz", + "integrity": "sha512-0GyVyEcGt9M7jHPCua16hNVtstUXE2R4HsnNubFYcDs7DJaLzh1dSiXsADDU/cNn/SqrLfa0vRKyjUmbx4rZLA==", "license": "MIT", "dependencies": { - "@tanstack/query-core": "5.101.4" + "@tanstack/query-core": "5.102.0" }, "funding": { "type": "github", diff --git a/frontend/package.json b/frontend/package.json index 0389baaf32..877c9f1b3b 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -46,7 +46,7 @@ "@stripe/stripe-js": "^9.10.0", "@supabase/supabase-js": "^2.47.13", "@tailwindcss/postcss": "^4.1.13", - "@tanstack/react-query": "^5.101.4", + "@tanstack/react-query": "^5.102.0", "@tanstack/react-table": "^9.1.2", "@tanstack/react-virtual": "^3.14.10", "@tauri-apps/api": "^2.10.1", From 0920ea9493598c40e893728117f94c458a51d63e Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 31 Aug 2026 10:17:05 +0100 Subject: [PATCH 27/37] build(deps): bump go-task/setup-task from 2.1.0 to 2.2.0 (#7532) Signed-off-by: dependabot[bot] --- .github/workflows/PR-Auto-Deploy-V2.yml | 2 +- .github/workflows/PR-Demo-Comment-with-react.yml | 2 +- .github/workflows/ai-engine.yml | 2 +- .github/workflows/backend-build.yml | 2 +- .github/workflows/build-enterprise.yml | 2 +- .github/workflows/check-generated-models.yml | 2 +- .github/workflows/check-licence.yml | 2 +- .github/workflows/check-openapi.yml | 2 +- .github/workflows/e2e-live.yml | 2 +- .github/workflows/e2e-stubbed.yml | 2 +- .github/workflows/frontend-a11y.yml | 2 +- .github/workflows/frontend-backend-licenses-update.yml | 4 ++-- .github/workflows/frontend-validation.yml | 2 +- .github/workflows/multiOSReleases.yml | 6 +++--- .github/workflows/nightly.yml | 6 +++--- .github/workflows/pre_commit.yml | 2 +- .github/workflows/push-docker.yml | 2 +- .github/workflows/swagger.yml | 2 +- .github/workflows/sync_files_v2.yml | 2 +- .github/workflows/tauri-build.yml | 2 +- .github/workflows/test-build-docker.yml | 2 +- 21 files changed, 26 insertions(+), 26 deletions(-) diff --git a/.github/workflows/PR-Auto-Deploy-V2.yml b/.github/workflows/PR-Auto-Deploy-V2.yml index c27fb6b5f2..c55d70fd4e 100644 --- a/.github/workflows/PR-Auto-Deploy-V2.yml +++ b/.github/workflows/PR-Auto-Deploy-V2.yml @@ -353,7 +353,7 @@ jobs: - name: Install Task for Storybook if: steps.sb-changes.outputs.storybook == 'true' - uses: go-task/setup-task@01a4adf9db2d14c1de7a560f09170b6e0df736aa # v2.1.0 + uses: go-task/setup-task@a00fbb05ce67b35648be3c78cbc9fd85354c757e # v2.2.0 - name: Build and deploy Storybook id: storybook diff --git a/.github/workflows/PR-Demo-Comment-with-react.yml b/.github/workflows/PR-Demo-Comment-with-react.yml index b43020ceb0..5a4fcc8053 100644 --- a/.github/workflows/PR-Demo-Comment-with-react.yml +++ b/.github/workflows/PR-Demo-Comment-with-react.yml @@ -206,7 +206,7 @@ jobs: distribution: "temurin" - name: Install Task - uses: go-task/setup-task@01a4adf9db2d14c1de7a560f09170b6e0df736aa # v2.1.0 + uses: go-task/setup-task@a00fbb05ce67b35648be3c78cbc9fd85354c757e # v2.2.0 - name: Run Gradle Command run: | if [ "${{ needs.check-comment.outputs.disable_security }}" == "true" ]; then diff --git a/.github/workflows/ai-engine.yml b/.github/workflows/ai-engine.yml index 357dfa1e49..29b8312f4f 100644 --- a/.github/workflows/ai-engine.yml +++ b/.github/workflows/ai-engine.yml @@ -36,7 +36,7 @@ jobs: engine/uv.lock - name: Install Task - uses: go-task/setup-task@01a4adf9db2d14c1de7a560f09170b6e0df736aa # v2.1.0 + uses: go-task/setup-task@a00fbb05ce67b35648be3c78cbc9fd85354c757e # v2.2.0 - name: Quality-check engine id: engine-check diff --git a/.github/workflows/backend-build.yml b/.github/workflows/backend-build.yml index 09c6bbc9a8..33b631ef86 100644 --- a/.github/workflows/backend-build.yml +++ b/.github/workflows/backend-build.yml @@ -52,7 +52,7 @@ jobs: distribution: "temurin" - name: Install Task - uses: go-task/setup-task@01a4adf9db2d14c1de7a560f09170b6e0df736aa # v2.1.0 + uses: go-task/setup-task@a00fbb05ce67b35648be3c78cbc9fd85354c757e # v2.2.0 - name: Check Java formatting (Spotless) # Runs once per matrix combination - pick the cheapest leg # (core - no proprietary, no saas) so we don't wait for the diff --git a/.github/workflows/build-enterprise.yml b/.github/workflows/build-enterprise.yml index a5a346db88..a3f10d82d4 100644 --- a/.github/workflows/build-enterprise.yml +++ b/.github/workflows/build-enterprise.yml @@ -95,7 +95,7 @@ jobs: cache: "npm" cache-dependency-path: frontend/package-lock.json - name: Install Task - uses: go-task/setup-task@01a4adf9db2d14c1de7a560f09170b6e0df736aa # v2.1.0 + uses: go-task/setup-task@a00fbb05ce67b35648be3c78cbc9fd85354c757e # v2.2.0 - name: Install Playwright (chromium only) run: task e2e:install -- chromium - name: Build frontend (needed for playwright's vite preview webServer) diff --git a/.github/workflows/check-generated-models.yml b/.github/workflows/check-generated-models.yml index f917a4c602..276a1d7530 100644 --- a/.github/workflows/check-generated-models.yml +++ b/.github/workflows/check-generated-models.yml @@ -75,7 +75,7 @@ jobs: cache-dependency-path: frontend/package-lock.json - name: Install Task - uses: go-task/setup-task@01a4adf9db2d14c1de7a560f09170b6e0df736aa # v2.1.0 + uses: go-task/setup-task@a00fbb05ce67b35648be3c78cbc9fd85354c757e # v2.2.0 - name: Verify generated models are up to date id: models-check diff --git a/.github/workflows/check-licence.yml b/.github/workflows/check-licence.yml index 7626122884..38b49097ed 100644 --- a/.github/workflows/check-licence.yml +++ b/.github/workflows/check-licence.yml @@ -38,7 +38,7 @@ jobs: distribution: "temurin" - name: Install Task - uses: go-task/setup-task@01a4adf9db2d14c1de7a560f09170b6e0df736aa # v2.1.0 + uses: go-task/setup-task@a00fbb05ce67b35648be3c78cbc9fd85354c757e # v2.2.0 - name: Check licenses for compatibility run: task backend:licenses:check env: diff --git a/.github/workflows/check-openapi.yml b/.github/workflows/check-openapi.yml index 47341834a3..5046c18e8c 100644 --- a/.github/workflows/check-openapi.yml +++ b/.github/workflows/check-openapi.yml @@ -39,7 +39,7 @@ jobs: distribution: "temurin" - name: Install Task - uses: go-task/setup-task@01a4adf9db2d14c1de7a560f09170b6e0df736aa # v2.1.0 + uses: go-task/setup-task@a00fbb05ce67b35648be3c78cbc9fd85354c757e # v2.2.0 - name: Generate OpenAPI documentation run: task backend:swagger env: diff --git a/.github/workflows/e2e-live.yml b/.github/workflows/e2e-live.yml index b04f5022cc..505addd3d7 100644 --- a/.github/workflows/e2e-live.yml +++ b/.github/workflows/e2e-live.yml @@ -45,7 +45,7 @@ jobs: cache: "npm" cache-dependency-path: frontend/package-lock.json - name: Install Task - uses: go-task/setup-task@01a4adf9db2d14c1de7a560f09170b6e0df736aa # v2.1.0 + uses: go-task/setup-task@a00fbb05ce67b35648be3c78cbc9fd85354c757e # v2.2.0 - name: Install Playwright (chromium only) run: task e2e:install -- chromium - name: Build frontend (production bundle for vite preview) diff --git a/.github/workflows/e2e-stubbed.yml b/.github/workflows/e2e-stubbed.yml index 5038a7585a..2553f38f84 100644 --- a/.github/workflows/e2e-stubbed.yml +++ b/.github/workflows/e2e-stubbed.yml @@ -44,7 +44,7 @@ jobs: cache: "npm" cache-dependency-path: frontend/package-lock.json - name: Install Task - uses: go-task/setup-task@01a4adf9db2d14c1de7a560f09170b6e0df736aa # v2.1.0 + uses: go-task/setup-task@a00fbb05ce67b35648be3c78cbc9fd85354c757e # v2.2.0 - name: Build frontend (production bundle for vite preview) env: VITE_BUILD_FOR_PREVIEW: "1" diff --git a/.github/workflows/frontend-a11y.yml b/.github/workflows/frontend-a11y.yml index 247d8375fc..f96496e181 100644 --- a/.github/workflows/frontend-a11y.yml +++ b/.github/workflows/frontend-a11y.yml @@ -36,7 +36,7 @@ jobs: cache: "npm" cache-dependency-path: frontend/package-lock.json - name: Install Task - uses: go-task/setup-task@01a4adf9db2d14c1de7a560f09170b6e0df736aa # v2.1.0 + uses: go-task/setup-task@a00fbb05ce67b35648be3c78cbc9fd85354c757e # v2.2.0 - name: a11y gate (changed stories) run: task frontend:storybook:a11y:changed -- origin/${{ github.base_ref || 'main' }} - name: Upload scan reports diff --git a/.github/workflows/frontend-backend-licenses-update.yml b/.github/workflows/frontend-backend-licenses-update.yml index 96e0edd8ac..44cf4afb75 100644 --- a/.github/workflows/frontend-backend-licenses-update.yml +++ b/.github/workflows/frontend-backend-licenses-update.yml @@ -97,7 +97,7 @@ jobs: run: npm ci --ignore-scripts --audit=false --fund=false - name: Install Task - uses: go-task/setup-task@01a4adf9db2d14c1de7a560f09170b6e0df736aa # v2.1.0 + uses: go-task/setup-task@a00fbb05ce67b35648be3c78cbc9fd85354c757e # v2.2.0 - name: Generate frontend license report (Push only) if: github.event_name == 'push' @@ -367,7 +367,7 @@ jobs: distribution: "temurin" - name: Install Task - uses: go-task/setup-task@01a4adf9db2d14c1de7a560f09170b6e0df736aa # v2.1.0 + uses: go-task/setup-task@a00fbb05ce67b35648be3c78cbc9fd85354c757e # v2.2.0 - name: Check licenses and generate report id: license-check diff --git a/.github/workflows/frontend-validation.yml b/.github/workflows/frontend-validation.yml index 2650a945d6..a553c48280 100644 --- a/.github/workflows/frontend-validation.yml +++ b/.github/workflows/frontend-validation.yml @@ -27,7 +27,7 @@ jobs: cache: "npm" cache-dependency-path: frontend/package-lock.json - name: Install Task - uses: go-task/setup-task@01a4adf9db2d14c1de7a560f09170b6e0df736aa # v2.1.0 + uses: go-task/setup-task@a00fbb05ce67b35648be3c78cbc9fd85354c757e # v2.2.0 - name: Quality-check frontend id: frontend-check run: task frontend:check:all diff --git a/.github/workflows/multiOSReleases.yml b/.github/workflows/multiOSReleases.yml index 9071997ad7..6d4258d085 100644 --- a/.github/workflows/multiOSReleases.yml +++ b/.github/workflows/multiOSReleases.yml @@ -69,7 +69,7 @@ jobs: distribution: "temurin" - name: Install Task - uses: go-task/setup-task@01a4adf9db2d14c1de7a560f09170b6e0df736aa # v2.1.0 + uses: go-task/setup-task@a00fbb05ce67b35648be3c78cbc9fd85354c757e # v2.2.0 - name: Get version number id: versionNumber run: | @@ -169,7 +169,7 @@ jobs: cache-dependency-path: frontend/package-lock.json - name: Install Task - uses: go-task/setup-task@01a4adf9db2d14c1de7a560f09170b6e0df736aa # v2.1.0 + uses: go-task/setup-task@a00fbb05ce67b35648be3c78cbc9fd85354c757e # v2.2.0 - name: Build JAR run: ./gradlew build ${{ matrix.variant.build_frontend && '-PbuildWithFrontend=true' || '' }} -x spotlessApply -x spotlessCheck -x test -x sonarqube @@ -268,7 +268,7 @@ jobs: distribution: ${{ matrix.platform == 'windows-11-arm' && 'microsoft' || 'temurin' }} - name: Install Task - uses: go-task/setup-task@01a4adf9db2d14c1de7a560f09170b6e0df736aa # v2.1.0 + uses: go-task/setup-task@a00fbb05ce67b35648be3c78cbc9fd85354c757e # v2.2.0 # Build the universal JRE before desktop:prepare so the jlink:runtime # task short-circuits on its `test -d runtime/jre` status check. diff --git a/.github/workflows/nightly.yml b/.github/workflows/nightly.yml index 5daa60f56f..63484ee7c0 100644 --- a/.github/workflows/nightly.yml +++ b/.github/workflows/nightly.yml @@ -38,7 +38,7 @@ jobs: cache-dependency-path: frontend/package-lock.json - name: Install Task - uses: go-task/setup-task@01a4adf9db2d14c1de7a560f09170b6e0df736aa # v2.1.0 + uses: go-task/setup-task@a00fbb05ce67b35648be3c78cbc9fd85354c757e # v2.2.0 - name: Install all Playwright browsers run: task e2e:install @@ -89,7 +89,7 @@ jobs: cache-dependency-path: frontend/package-lock.json - name: Install Task - uses: go-task/setup-task@01a4adf9db2d14c1de7a560f09170b6e0df736aa # v2.1.0 + uses: go-task/setup-task@a00fbb05ce67b35648be3c78cbc9fd85354c757e # v2.2.0 - name: a11y gate (every story, ${{ matrix.theme }}) run: task frontend:storybook:a11y:${{ matrix.theme }} @@ -162,7 +162,7 @@ jobs: engine/uv.lock - name: Install Task - uses: go-task/setup-task@01a4adf9db2d14c1de7a560f09170b6e0df736aa # v2.1.0 + uses: go-task/setup-task@a00fbb05ce67b35648be3c78cbc9fd85354c757e # v2.2.0 - name: Start the fat image with login and storage enabled run: docker compose -f docker/embedded/compose/test_cicd.yml up -d --build diff --git a/.github/workflows/pre_commit.yml b/.github/workflows/pre_commit.yml index fbd9efc474..f3442086a0 100644 --- a/.github/workflows/pre_commit.yml +++ b/.github/workflows/pre_commit.yml @@ -33,7 +33,7 @@ jobs: engine/uv.lock - name: Install Task - uses: go-task/setup-task@01a4adf9db2d14c1de7a560f09170b6e0df736aa # v2.1.0 + uses: go-task/setup-task@a00fbb05ce67b35648be3c78cbc9fd85354c757e # v2.2.0 - name: Run pre-commit checks run: task pre-commit diff --git a/.github/workflows/push-docker.yml b/.github/workflows/push-docker.yml index 8e8b26be91..b3c6d442b6 100644 --- a/.github/workflows/push-docker.yml +++ b/.github/workflows/push-docker.yml @@ -88,7 +88,7 @@ jobs: uses: docker/setup-buildx-action@37fe631027851001ddb9b187196cc803df7f5f0e # v4.3.0 - name: Install Task - uses: go-task/setup-task@01a4adf9db2d14c1de7a560f09170b6e0df736aa # v2.1.0 + uses: go-task/setup-task@a00fbb05ce67b35648be3c78cbc9fd85354c757e # v2.2.0 - name: Get version number id: versionNumber run: echo "versionNumber=$(./gradlew printVersion --quiet | tail -1)" >> $GITHUB_OUTPUT diff --git a/.github/workflows/swagger.yml b/.github/workflows/swagger.yml index 1f53edd17b..d7408dcfde 100644 --- a/.github/workflows/swagger.yml +++ b/.github/workflows/swagger.yml @@ -63,7 +63,7 @@ jobs: SWAGGERHUB_USER: "Frooodle" - name: Install Task - uses: go-task/setup-task@01a4adf9db2d14c1de7a560f09170b6e0df736aa # v2.1.0 + uses: go-task/setup-task@a00fbb05ce67b35648be3c78cbc9fd85354c757e # v2.2.0 - name: Get version number id: versionNumber run: echo "versionNumber=$(./gradlew printVersion --quiet | tail -1)" >> $GITHUB_OUTPUT diff --git a/.github/workflows/sync_files_v2.yml b/.github/workflows/sync_files_v2.yml index f688476159..d38c14ddba 100644 --- a/.github/workflows/sync_files_v2.yml +++ b/.github/workflows/sync_files_v2.yml @@ -65,7 +65,7 @@ jobs: uv sync --project engine --locked --group tools - name: Install Task - uses: go-task/setup-task@01a4adf9db2d14c1de7a560f09170b6e0df736aa # v2.1.0 + uses: go-task/setup-task@a00fbb05ce67b35648be3c78cbc9fd85354c757e # v2.2.0 - name: Sync translation TOML files run: | diff --git a/.github/workflows/tauri-build.yml b/.github/workflows/tauri-build.yml index 3b7a0b04fa..157c4ead0c 100644 --- a/.github/workflows/tauri-build.yml +++ b/.github/workflows/tauri-build.yml @@ -212,7 +212,7 @@ jobs: distribution: ${{ matrix.platform == 'windows-11-arm' && 'microsoft' || 'temurin' }} - name: Setup Task - uses: go-task/setup-task@01a4adf9db2d14c1de7a560f09170b6e0df736aa # v2.1.0 + uses: go-task/setup-task@a00fbb05ce67b35648be3c78cbc9fd85354c757e # v2.2.0 - name: Build universal macOS JRE if: matrix.platform == 'macos-15' diff --git a/.github/workflows/test-build-docker.yml b/.github/workflows/test-build-docker.yml index e38fcb4cd6..04a6380586 100644 --- a/.github/workflows/test-build-docker.yml +++ b/.github/workflows/test-build-docker.yml @@ -127,7 +127,7 @@ jobs: distribution: "temurin" - name: Install Task - uses: go-task/setup-task@01a4adf9db2d14c1de7a560f09170b6e0df736aa # v2.1.0 + uses: go-task/setup-task@a00fbb05ce67b35648be3c78cbc9fd85354c757e # v2.2.0 - name: Build application run: task backend:build env: From 01c908e95d6d0ee062f9467fb4942cf27f712986 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 31 Aug 2026 11:37:12 +0100 Subject: [PATCH 28/37] build(deps-dev): bump openai from 2.53.0 to 3.3.1 in /engine (#7700) Signed-off-by: dependabot[bot] --- engine/pyproject.toml | 2 +- engine/uv.lock | 24 +++++------------------- 2 files changed, 6 insertions(+), 20 deletions(-) diff --git a/engine/pyproject.toml b/engine/pyproject.toml index 179fe8c1d1..d924eab264 100644 --- a/engine/pyproject.toml +++ b/engine/pyproject.toml @@ -51,7 +51,7 @@ tools = [ "defusedxml>=0.7.1", "fonttools>=4.63.0", "fpdf2>=2.8.8", - "openai>=2.53.0", + "openai>=3.3.1", "requests>=2.34.2", "tomli-w>=1.2.0", "tomlkit>=0.15.1", diff --git a/engine/uv.lock b/engine/uv.lock index 8c76b57b3b..ae023292b7 100644 --- a/engine/uv.lock +++ b/engine/uv.lock @@ -501,7 +501,7 @@ tools = [ { name = "defusedxml", specifier = ">=0.7.1" }, { name = "fonttools", specifier = ">=4.63.0" }, { name = "fpdf2", specifier = ">=2.8.8" }, - { name = "openai", specifier = ">=2.53.0" }, + { name = "openai", specifier = ">=3.3.1" }, { name = "requests", specifier = ">=2.34.2" }, { name = "tomli-w", specifier = ">=1.2.0" }, { name = "tomlkit", specifier = ">=0.15.1" }, @@ -844,21 +844,19 @@ wheels = [ [[package]] name = "openai" -version = "2.53.0" +version = "3.3.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "anyio" }, - { name = "distro" }, - { name = "httpx" }, + { name = "httpx2" }, { name = "jiter" }, { name = "pydantic" }, { name = "sniffio" }, - { name = "tqdm" }, { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/ef/cf/36e3e7235fdf6d125c052acc0970924611b17a20a4fe580596faf4566a65/openai-2.53.0.tar.gz", hash = "sha256:baf5802ad08980e1d9d561e1b996e800c8bcd14af5847c6d0e7a5cc59e4d4116", size = 1099435, upload-time = "2026-08-03T21:42:01.664Z" } +sdist = { url = "https://files.pythonhosted.org/packages/7d/9c/ba0c292b4032ede74c249ca314ad64eb1bb5a03a843f6e01facb02f80cd8/openai-3.3.1.tar.gz", hash = "sha256:6f22807de1a976c932cecda620e8172a8c3fdbaeed29c7f21564e0c2410edf56", size = 1282113, upload-time = "2026-08-19T16:31:35.006Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/78/0f/cc6afea3542a5142c5d8fc8211c5e059a8375105d004a41dfa2c7948dbb0/openai-2.53.0-py3-none-any.whl", hash = "sha256:c694ffc747a3c4d1663ef2b07b811315a476164ee5efa3a993967349ebca7618", size = 1659829, upload-time = "2026-08-03T21:41:59.581Z" }, + { url = "https://files.pythonhosted.org/packages/6a/db/2b7a1b3de659bb82aef979116c74e809982b13e42c057759767552b5155f/openai-3.3.1-py3-none-any.whl", hash = "sha256:9652df7fdf8ee6f5bd58e0a12f2b1d414a18e0f06bb7a9a57c8643a5f5469bd3", size = 1690337, upload-time = "2026-08-19T16:31:32.812Z" }, ] [[package]] @@ -1566,18 +1564,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/13/bc/8c13eb66537dce1d2bd3a57132902f38d0e7f5bb46fa9f4daed9fe9d76ee/tomlkit-0.15.1-py3-none-any.whl", hash = "sha256:177a05aece5a8ca5266fd3c448abb47b8d352f09d477d3ca8332db4d89b24304", size = 49449, upload-time = "2026-07-17T01:48:05.728Z" }, ] -[[package]] -name = "tqdm" -version = "4.70.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "colorama", marker = "sys_platform == 'win32'" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/21/3b/6c24bec5be5e743ffd99576daa5cc077722fc7d5bbc00bd133fa0c698dc6/tqdm-4.70.0.tar.gz", hash = "sha256:55b0b0dbd97462d06ebee91e4dac24ed4d4702be82b24f07e6c1d27e08cea220", size = 795438, upload-time = "2026-07-27T11:33:15.271Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/f9/1c/01bfd571a64e7f270e6bab5e33777debe0edc56759233ce84f27dec92d14/tqdm-4.70.0-py3-none-any.whl", hash = "sha256:7f585706bfddbdebf89daac705b2dfcc16890130727d3197ca62c732b4310953", size = 80184, upload-time = "2026-07-27T11:33:13.167Z" }, -] - [[package]] name = "truststore" version = "0.10.4" From af97e1b27bca7ba84bfb7448a6fc3512f9873670 Mon Sep 17 00:00:00 2001 From: "stirlingbot[bot]" <195170888+stirlingbot[bot]@users.noreply.github.com> Date: Mon, 31 Aug 2026 12:45:42 +0100 Subject: [PATCH 29/37] Update Backend 3rd Party Licenses (#7713) Signed-off-by: stirlingbot[bot] --- .../resources/static/3rdPartyLicenses.json | 101 +----------------- 1 file changed, 5 insertions(+), 96 deletions(-) diff --git a/app/core/src/main/resources/static/3rdPartyLicenses.json b/app/core/src/main/resources/static/3rdPartyLicenses.json index a0dae640e0..6b846053e4 100644 --- a/app/core/src/main/resources/static/3rdPartyLicenses.json +++ b/app/core/src/main/resources/static/3rdPartyLicenses.json @@ -94,7 +94,7 @@ { "moduleName": "com.fasterxml.jackson.core:jackson-core", "moduleUrl": "https://github.com/FasterXML/jackson-core", - "moduleVersion": "2.22.1", + "moduleVersion": "2.22.2", "moduleLicense": "The Apache Software License, Version 2.0", "moduleLicenseUrl": "https://www.apache.org/licenses/LICENSE-2.0.txt" }, @@ -108,7 +108,7 @@ { "moduleName": "com.fasterxml.jackson.core:jackson-databind", "moduleUrl": "https://github.com/FasterXML/jackson", - "moduleVersion": "2.22.1", + "moduleVersion": "2.22.2", "moduleLicense": "The Apache Software License, Version 2.0", "moduleLicenseUrl": "https://www.apache.org/licenses/LICENSE-2.0.txt" }, @@ -143,7 +143,7 @@ { "moduleName": "com.fasterxml.jackson:jackson-bom", "moduleUrl": "https://github.com/FasterXML/jackson-bom", - "moduleVersion": "2.22.1", + "moduleVersion": "2.22.2", "moduleLicense": "The Apache Software License, Version 2.0", "moduleLicenseUrl": "http://www.apache.org/licenses/LICENSE-2.0.txt" }, @@ -440,42 +440,14 @@ { "moduleName": "com.stirling:jpdfium", "moduleUrl": "https://github.com/Stirling-Tools/JPDFium", - "moduleVersion": "1.0.4", - "moduleLicense": "MIT License", - "moduleLicenseUrl": "https://opensource.org/licenses/MIT" - }, - { - "moduleName": "com.stirling:jpdfium-natives-darwin-arm64", - "moduleUrl": "https://github.com/Stirling-Tools/JPDFium", - "moduleVersion": "1.0.4", - "moduleLicense": "MIT License", - "moduleLicenseUrl": "https://opensource.org/licenses/MIT" - }, - { - "moduleName": "com.stirling:jpdfium-natives-darwin-x64", - "moduleUrl": "https://github.com/Stirling-Tools/JPDFium", - "moduleVersion": "1.0.4", - "moduleLicense": "MIT License", - "moduleLicenseUrl": "https://opensource.org/licenses/MIT" - }, - { - "moduleName": "com.stirling:jpdfium-natives-linux-arm64", - "moduleUrl": "https://github.com/Stirling-Tools/JPDFium", - "moduleVersion": "1.0.4", + "moduleVersion": "1.1.3", "moduleLicense": "MIT License", "moduleLicenseUrl": "https://opensource.org/licenses/MIT" }, { "moduleName": "com.stirling:jpdfium-natives-linux-x64", "moduleUrl": "https://github.com/Stirling-Tools/JPDFium", - "moduleVersion": "1.0.4", - "moduleLicense": "MIT License", - "moduleLicenseUrl": "https://opensource.org/licenses/MIT" - }, - { - "moduleName": "com.stirling:jpdfium-natives-windows-x64", - "moduleUrl": "https://github.com/Stirling-Tools/JPDFium", - "moduleVersion": "1.0.4", + "moduleVersion": "1.1.3", "moduleLicense": "MIT License", "moduleLicenseUrl": "https://opensource.org/licenses/MIT" }, @@ -521,36 +493,18 @@ "moduleLicense": "GNU General Public License, version 2 with the GNU Classpath Exception", "moduleLicenseUrl": "https://www.gnu.org/software/classpath/license.html" }, - { - "moduleName": "com.twelvemonkeys.common:common-image", - "moduleVersion": "3.13.1", - "moduleLicense": "The BSD License", - "moduleLicenseUrl": "https://github.com/haraldk/TwelveMonkeys#license" - }, { "moduleName": "com.twelvemonkeys.common:common-image", "moduleVersion": "3.14.0", "moduleLicense": "The BSD License", "moduleLicenseUrl": "https://github.com/haraldk/TwelveMonkeys#license" }, - { - "moduleName": "com.twelvemonkeys.common:common-io", - "moduleVersion": "3.13.1", - "moduleLicense": "The BSD License", - "moduleLicenseUrl": "https://github.com/haraldk/TwelveMonkeys#license" - }, { "moduleName": "com.twelvemonkeys.common:common-io", "moduleVersion": "3.14.0", "moduleLicense": "The BSD License", "moduleLicenseUrl": "https://github.com/haraldk/TwelveMonkeys#license" }, - { - "moduleName": "com.twelvemonkeys.common:common-lang", - "moduleVersion": "3.13.1", - "moduleLicense": "The BSD License", - "moduleLicenseUrl": "https://github.com/haraldk/TwelveMonkeys#license" - }, { "moduleName": "com.twelvemonkeys.common:common-lang", "moduleVersion": "3.14.0", @@ -569,12 +523,6 @@ "moduleLicense": "The BSD License", "moduleLicenseUrl": "https://github.com/haraldk/TwelveMonkeys#license" }, - { - "moduleName": "com.twelvemonkeys.imageio:imageio-core", - "moduleVersion": "3.13.1", - "moduleLicense": "The BSD License", - "moduleLicenseUrl": "https://github.com/haraldk/TwelveMonkeys#license" - }, { "moduleName": "com.twelvemonkeys.imageio:imageio-core", "moduleVersion": "3.14.0", @@ -587,12 +535,6 @@ "moduleLicense": "The BSD License", "moduleLicenseUrl": "https://github.com/haraldk/TwelveMonkeys#license" }, - { - "moduleName": "com.twelvemonkeys.imageio:imageio-metadata", - "moduleVersion": "3.13.1", - "moduleLicense": "The BSD License", - "moduleLicenseUrl": "https://github.com/haraldk/TwelveMonkeys#license" - }, { "moduleName": "com.twelvemonkeys.imageio:imageio-metadata", "moduleVersion": "3.14.0", @@ -605,24 +547,12 @@ "moduleLicense": "The BSD License", "moduleLicenseUrl": "https://github.com/haraldk/TwelveMonkeys#license" }, - { - "moduleName": "com.twelvemonkeys.imageio:imageio-tiff", - "moduleVersion": "3.13.1", - "moduleLicense": "The BSD License", - "moduleLicenseUrl": "https://github.com/haraldk/TwelveMonkeys#license" - }, { "moduleName": "com.twelvemonkeys.imageio:imageio-tiff", "moduleVersion": "3.14.0", "moduleLicense": "The BSD License", "moduleLicenseUrl": "https://github.com/haraldk/TwelveMonkeys#license" }, - { - "moduleName": "com.twelvemonkeys.imageio:imageio-webp", - "moduleVersion": "3.13.1", - "moduleLicense": "The BSD License", - "moduleLicenseUrl": "https://github.com/haraldk/TwelveMonkeys#license" - }, { "moduleName": "com.twelvemonkeys.imageio:imageio-webp", "moduleVersion": "3.14.0", @@ -769,13 +699,6 @@ "moduleLicense": "The Apache Software License, Version 2.0", "moduleLicenseUrl": "http://www.apache.org/licenses/LICENSE-2.0.txt" }, - { - "moduleName": "commons-beanutils:commons-beanutils", - "moduleUrl": "https://commons.apache.org/proper/commons-beanutils", - "moduleVersion": "1.11.0", - "moduleLicense": "Apache-2.0", - "moduleLicenseUrl": "https://www.apache.org/licenses/LICENSE-2.0.txt" - }, { "moduleName": "commons-cli:commons-cli", "moduleUrl": "http://commons.apache.org/proper/commons-cli/", @@ -790,13 +713,6 @@ "moduleLicense": "Apache-2.0", "moduleLicenseUrl": "https://www.apache.org/licenses/LICENSE-2.0.txt" }, - { - "moduleName": "commons-collections:commons-collections", - "moduleUrl": "http://commons.apache.org/collections/", - "moduleVersion": "3.2.2", - "moduleLicense": "Apache License, Version 2.0", - "moduleLicenseUrl": "http://www.apache.org/licenses/LICENSE-2.0.txt" - }, { "moduleName": "commons-io:commons-io", "moduleUrl": "https://commons.apache.org/proper/commons-io/", @@ -1360,13 +1276,6 @@ "moduleLicense": "Apache-2.0", "moduleLicenseUrl": "https://www.apache.org/licenses/LICENSE-2.0.txt" }, - { - "moduleName": "org.apache.commons:commons-math3", - "moduleUrl": "http://commons.apache.org/proper/commons-math/", - "moduleVersion": "3.6.1", - "moduleLicense": "Apache License, Version 2.0", - "moduleLicenseUrl": "http://www.apache.org/licenses/LICENSE-2.0.txt" - }, { "moduleName": "org.apache.commons:commons-text", "moduleUrl": "https://commons.apache.org/proper/commons-text", From d55d8acbfa5f56174bf7ee00b7c6f53944180099 Mon Sep 17 00:00:00 2001 From: ConnorYoh <40631091+ConnorYoh@users.noreply.github.com> Date: Tue, 1 Sep 2026 08:56:35 +0000 Subject: [PATCH 30/37] fix(portal): keep the processor's cache across a trip to the editor (#7729) # Description of Changes ## The problem The portal's query client was created per mount: ```ts const [queryClient] = useState(createPortalQueryClient); ``` The portal is a route (`/processor/*`, a lazy element), and the switch to the editor is a client-side `navigate()`. So leaving the processor unmounts `PortalApp`, the client goes with the component, and the cache goes with the client. Coming back refetches everything, whether or not anything changed: four requests for the Users page alone (roster, grants, teams, auth config), and 21 `useQuery` sites across the portal. The editor's client sits above the router in `AppProviders` and survives the same trip. The round trip only ever cost in one direction. ## The fix The module already kept the instance in a module-level slot so `tryGetPortalQueryClient()` could find it. It just replaced it on every mount instead of reusing it, so the change is to create it lazily and hand out the same one: ```ts export function getPortalQueryClient(): QueryClient { current ??= new QueryClient({ defaultOptions: { queries: baseQueryOptions } }); return current; } ``` Still a separate instance from the editor's. The two namespace their keys apart (`["portal", ...]` against `["editor", ...]`) and invalidate independently, which this does not change. ## What this does not do `gcTime` is 5 minutes, from the shared `baseQueryOptions`. An entry with no observer is still collected on that timer, so this warms a quick trip to the editor and back, not a return after a long editing session. Raising the portal's `gcTime` is a separate decision and is not made here. ## Why it is safe **Signing out.** A cache that outlives a mount must not outlive a session, because the portal's holds the admin roster, emails and roles. Logout goes through `window.location.assign`, a full page load, so the whole JS context is discarded and no cache can survive it. Nothing in the codebase calls `queryClient.clear()` on sign-out, and nothing needs to. If logout ever becomes a client-side navigation, this needs an explicit reset, and `resetPortalQueryClient()` is the hook for it. **The one caller of the null check.** `resolveTeam` in `saas/portal/usersBackend.ts` uses `tryGetPortalQueryClient()` and falls back to a direct fetch when there is no client, which its comment describes as the unit-test path; the cache path is preferred because it honours both `staleTime` and invalidation. A longer-lived client means the preferred path is taken more often, not less. ## Testing Three tests in `queryClient.test.tsx`, and the first two fail if the client goes back to being created per call: | | | |---|---| | A remount is served from cache rather than refetching | the behaviour this changes | | Every caller gets the same instance | the mechanism | | No client is reported until the portal first mounts | the contract `resolveTeam` reads | The three existing portal caching suites called the factory expecting a fresh client per case. They now call `resetPortalQueryClient()` in a `beforeEach`, which is what keeps `sharing.test.tsx`'s "a later screen refetches nothing" case honest rather than passing on a leaked cache. `task frontend:check` passes typecheck, lint and oxfmt, and 2402 of 2404 editor tests. The two failures, `workbenchSession.test.ts` and `notificationActions.test.tsx`, are untouched here and fail the same way on `main`. --- frontend/editor/src/portal/PortalApp.tsx | 6 +- .../src/portal/queries/sharing.test.tsx | 12 +++- .../editor/src/portal/queryClient.test.tsx | 64 +++++++++++++++++++ frontend/editor/src/portal/queryClient.ts | 27 ++++++-- .../src/portal/views/Users.caching.test.tsx | 12 +++- .../src/portal/views/teamMyCache.saas.test.ts | 10 ++- 6 files changed, 116 insertions(+), 15 deletions(-) create mode 100644 frontend/editor/src/portal/queryClient.test.tsx diff --git a/frontend/editor/src/portal/PortalApp.tsx b/frontend/editor/src/portal/PortalApp.tsx index 3122016436..9109946929 100644 --- a/frontend/editor/src/portal/PortalApp.tsx +++ b/frontend/editor/src/portal/PortalApp.tsx @@ -1,11 +1,11 @@ -import { useState, type ReactNode } from "react"; +import { type ReactNode } from "react"; import { QueryClientProvider } from "@tanstack/react-query"; import { PortalAuthBoundary } from "@portal/auth/PortalAuthBoundary"; import { ThemeProvider, useTheme } from "@portal/contexts/ThemeContext"; import { SuiProvider } from "@portal/theme/SuiProvider"; import { PortalProviders } from "@portal/PortalProviders"; import { ToolRegistryProvider } from "@app/contexts/ToolRegistryProvider"; -import { createPortalQueryClient } from "@portal/queryClient"; +import { getPortalQueryClient } from "@portal/queryClient"; // Reset + typography, scoped to .portal-scope below. import "@portal/theme/base.css"; @@ -30,7 +30,7 @@ function ThemedSuiProvider({ children }: { children: ReactNode }) { * self-hosted mounts the account-link layer, SaaS does not. */ export function PortalApp() { - const [queryClient] = useState(createPortalQueryClient); + const queryClient = getPortalQueryClient(); return ( diff --git a/frontend/editor/src/portal/queries/sharing.test.tsx b/frontend/editor/src/portal/queries/sharing.test.tsx index 5cfb9d1629..97e23f9244 100644 --- a/frontend/editor/src/portal/queries/sharing.test.tsx +++ b/frontend/editor/src/portal/queries/sharing.test.tsx @@ -12,7 +12,10 @@ import { render, waitFor } from "@testing-library/react"; import { QueryClientProvider } from "@tanstack/react-query"; import { setupServer } from "msw/node"; import { http, HttpResponse } from "msw"; -import { createPortalQueryClient } from "@portal/queryClient"; +import { + getPortalQueryClient, + resetPortalQueryClient, +} from "@portal/queryClient"; import { usePoliciesOverview } from "@portal/queries/policies"; import { useProcessorFlow } from "@portal/queries/processorFlow"; @@ -70,9 +73,12 @@ function PoliciesConsumer() { return null; } +// The client outlives a mount now, so each case starts from a cold one. +beforeEach(resetPortalQueryClient); + describe("portal query sharing", () => { it("in-view: multiple consumers of the same endpoints fetch each once", async () => { - const client = createPortalQueryClient(); + const client = getPortalQueryClient(); render( @@ -86,7 +92,7 @@ describe("portal query sharing", () => { }); it("cross-view: a later screen reusing the data refetches nothing", async () => { - const client = createPortalQueryClient(); + const client = getPortalQueryClient(); const home = render( diff --git a/frontend/editor/src/portal/queryClient.test.tsx b/frontend/editor/src/portal/queryClient.test.tsx new file mode 100644 index 0000000000..a750eccc0e --- /dev/null +++ b/frontend/editor/src/portal/queryClient.test.tsx @@ -0,0 +1,64 @@ +import { describe, it, expect, beforeEach, vi } from "vitest"; +import type { ReactNode } from "react"; +import { render, screen } from "@testing-library/react"; +import { QueryClientProvider, useQuery } from "@tanstack/react-query"; +import { + getPortalQueryClient, + resetPortalQueryClient, + tryGetPortalQueryClient, +} from "@portal/queryClient"; + +const fetchThing = vi.fn(async () => "loaded"); + +/** Stands in for any portal view: mounts, reads one key, unmounts with the route. */ +function PortalRoute() { + const { data } = useQuery({ + queryKey: ["portal", "thing"], + queryFn: fetchThing, + }); + return {data ?? "pending"}; +} + +function mountRoute() { + const Wrapper = ({ children }: { children: ReactNode }) => ( + + {children} + + ); + return render( + + + , + ); +} + +describe("portal query client lifetime", () => { + beforeEach(() => { + resetPortalQueryClient(); + fetchThing.mockClear(); + }); + + it("serves a remount from cache instead of refetching", async () => { + const first = mountRoute(); + await screen.findByText("loaded"); + expect(fetchThing).toHaveBeenCalledTimes(1); + + // Switching to the editor unmounts the portal route. + first.unmount(); + + mountRoute(); + // Painted from cache, not after a round trip. + expect(screen.getByText("loaded")).toBeInTheDocument(); + expect(fetchThing).toHaveBeenCalledTimes(1); + }); + + it("hands every caller the same instance", () => { + expect(getPortalQueryClient()).toBe(getPortalQueryClient()); + }); + + it("reports no client until the portal first mounts", () => { + expect(tryGetPortalQueryClient()).toBeNull(); + const client = getPortalQueryClient(); + expect(tryGetPortalQueryClient()).toBe(client); + }); +}); diff --git a/frontend/editor/src/portal/queryClient.ts b/frontend/editor/src/portal/queryClient.ts index 5c0404f17d..bdd6b3b2ec 100644 --- a/frontend/editor/src/portal/queryClient.ts +++ b/frontend/editor/src/portal/queryClient.ts @@ -3,13 +3,32 @@ import { baseQueryOptions } from "@app/query/queryClient"; let current: QueryClient | null = null; -/** Own instance, shared defaults — the portal and editor are sibling routes. */ -export function createPortalQueryClient(): QueryClient { - current = new QueryClient({ defaultOptions: { queries: baseQueryOptions } }); +/** + * One client for the session, not one per mount. The portal is a route, so + * switching to the editor unmounts it, and a per-mount client would throw the + * cache away and refetch everything on the way back. The editor's own client + * sits above the router and never pays that. + * + * Still a separate instance from the editor's: the two namespace their keys + * apart and invalidate independently. + */ +export function getPortalQueryClient(): QueryClient { + current ??= new QueryClient({ + defaultOptions: { queries: baseQueryOptions }, + }); return current; } -/** Null until the portal mounts, so resolveTeam can fall back to a direct fetch. */ +/** Null until the portal first mounts, so resolveTeam can fall back to a direct fetch. */ export function tryGetPortalQueryClient(): QueryClient | null { return current; } + +/** + * Drops the cache and the instance holding it. For tests, which need a cold + * start between cases; the app never calls it, because signing out is a full + * page load. + */ +export function resetPortalQueryClient(): void { + current = null; +} diff --git a/frontend/editor/src/portal/views/Users.caching.test.tsx b/frontend/editor/src/portal/views/Users.caching.test.tsx index 64d839ac62..a619d883b7 100644 --- a/frontend/editor/src/portal/views/Users.caching.test.tsx +++ b/frontend/editor/src/portal/views/Users.caching.test.tsx @@ -17,7 +17,10 @@ import { teamSaasHandlers, resetTeamSaasStore, } from "@portal/mocks/handlers/teamSaas"; -import { createPortalQueryClient } from "@portal/queryClient"; +import { + getPortalQueryClient, + resetPortalQueryClient, +} from "@portal/queryClient"; import { qk } from "@portal/queries/keys"; /** @@ -98,11 +101,14 @@ function renderUsers(client: QueryClient): RenderResult { ); } +// The client outlives a mount now, so each case starts from a cold one. +beforeEach(resetPortalQueryClient); + describe("Users view caching", () => { it("serves the roster from cache on remount (no refetch)", async () => { // One client across both mounts — the real app keeps it at the portal root, // above the router, for exactly this reason. - const client = createPortalQueryClient(); + const client = getPortalQueryClient(); const first = renderUsers(client); expect(await screen.findByText("leader@acme.com")).toBeInTheDocument(); @@ -116,7 +122,7 @@ describe("Users view caching", () => { }); it("collapses the SaaS /team/my call to one per mount", async () => { - const client = createPortalQueryClient(); + const client = getPortalQueryClient(); renderUsers(client); await screen.findByText("leader@acme.com"); diff --git a/frontend/editor/src/portal/views/teamMyCache.saas.test.ts b/frontend/editor/src/portal/views/teamMyCache.saas.test.ts index a4ba296535..8534624c52 100644 --- a/frontend/editor/src/portal/views/teamMyCache.saas.test.ts +++ b/frontend/editor/src/portal/views/teamMyCache.saas.test.ts @@ -10,7 +10,10 @@ import { } from "vitest"; import { setupServer } from "msw/node"; import { http, HttpResponse } from "msw"; -import { createPortalQueryClient } from "@portal/queryClient"; +import { + getPortalQueryClient, + resetPortalQueryClient, +} from "@portal/queryClient"; import { qk } from "@portal/queries/keys"; import { usersBackend } from "@app/portal/usersBackend"; @@ -68,9 +71,12 @@ beforeEach(() => { teamName = "Old name"; }); +// The client outlives a mount now, so each case starts from a cold one. +beforeEach(resetPortalQueryClient); + describe("SaaS /team/my resolution cache", () => { it("dedupes within staleTime but re-resolves after invalidation", async () => { - const client = createPortalQueryClient(); + const client = getPortalQueryClient(); // Two resolves within staleTime → one network call (the collapse). expect((await usersBackend.fetchTeams())[0]?.name).toBe("Old name"); From 31d52d4c327810450f575889ffccdd74a1e49593 Mon Sep 17 00:00:00 2001 From: ConnorYoh <40631091+ConnorYoh@users.noreply.github.com> Date: Tue, 1 Sep 2026 10:39:57 +0000 Subject: [PATCH 31/37] Connect flow for self-hosted account linking, and the triggers that drive it (#7415) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replaces the bare account-link login box with a guided Connect flow, and wires up the triggers that actually put it in front of someone. ## Top bar image ## The modal Three steps on the portal's own `FlowModal` + `StepModalHeader`, the shells procurement and prepay already wear: 1. **What you unlock** — six benefits as a plain list. image 2. **Sign in** — the existing `SupabaseLoginForm`, reseated. image 3. **Connected** — confirms, then deep links into Users, Pipelines and Policies. image Re-auth stays a single step with no pitch and no success screen. ## The triggers **`LinkGate` stops being dead code.** It was built as the drop-anywhere "link to unlock" wrapper and was imported by nothing. It is now a blocking empty state that replaces the feature it guards, wired into Pipelines, Policies, Users, Sources and Integrations. **Scoped to creating and editing, never viewing.** Existing pipelines, policies, sources and connections keep listing and running, so upgrading an unlinked instance cannot take away something that already works. The clicks that would open a builder or a create modal ask for the connection first, which is the moment an admin has already declared intent. ## Capability signal `accountLinkAvailable` on `/api/v1/config/app-config`. Gating needs two facts: whether the instance is linked (`LinkContext`) and whether it *could* be (this flag). The account-link endpoints 404 when the feature flag is off, which the client cannot distinguish from "not linked yet" — so gating on link state alone would lock all five views on every default install with no way out. `useConnectGate` holds that decision in one place and shares the app-config query key, so it costs no extra request. Read from the environment rather than `AccountLinkProperties` because `:core` cannot depend on `:proprietary`. --- .../controller/api/misc/ConfigController.java | 13 + .../public/locales/en-US/translation.toml | 72 ++-- frontend/editor/src/core/types/appConfig.ts | 6 + .../editor/src/portal/PortalProviders.tsx | 15 +- frontend/editor/src/portal/ViewRouter.tsx | 16 +- .../portal/components/ConnectAccountRail.css | 53 +++ .../components/ConnectAccountRail.stories.tsx | 46 +++ .../portal/components/ConnectAccountRail.tsx | 57 ++++ .../editor/src/portal/components/HomeHero.tsx | 2 + .../editor/src/portal/components/Sidebar.tsx | 5 + .../account-link/ConnectCallbackHost.tsx | 144 +++----- .../account-link/ConnectCallbackView.tsx | 66 ++-- .../account-link/ConnectGuardedRoute.tsx | 26 ++ .../account-link/LinkAccountModal.css | 26 -- .../account-link/LinkAccountModal.test.tsx | 186 +++++++++-- .../account-link/LinkAccountModal.tsx | 310 +++++++++++------- .../components/account-link/LinkGate.tsx | 52 --- .../account-link/connect/ConnectAskStep.tsx | 52 +++ .../connect/ConnectBenefitsSlide.stories.tsx | 12 + .../connect/ConnectBenefitsSlide.tsx | 55 ++++ .../connect/ConnectDoneSlide.test.tsx | 84 +++++ .../account-link/connect/ConnectDoneSlide.tsx | 157 +++++++++ .../connect/ConnectHandoffGhost.tsx | 28 ++ .../account-link/connect/connect.css | 112 +++++++ .../billing/LinkAccountPrompt.stories.tsx | 14 - .../components/billing/LinkAccountPrompt.tsx | 33 -- .../billing/PortalBillingGate.test.tsx | 77 ++++- .../components/billing/PortalBillingGate.tsx | 26 +- .../components/shared/StepModalHeader.css | 2 +- .../components/shared/StepModalHeader.tsx | 4 +- .../src/portal/components/sidebarGroups.tsx | 4 +- .../src/portal/contexts/LinkContext.tsx | 8 + .../editor/src/portal/contexts/UIContext.tsx | 22 ++ .../src/portal/hooks/useConnectGate.test.tsx | 78 +++++ .../editor/src/portal/hooks/useConnectGate.ts | 63 ++++ .../src/portal/hooks/useConnectHandoff.ts | 64 ++++ .../portal/hooks/useConnectPrompt.test.tsx | 74 +++++ .../src/portal/hooks/useConnectPrompt.ts | 37 +++ .../portal/hooks/useDevConnectBypass.test.tsx | 53 +++ .../src/portal/hooks/useDevConnectBypass.ts | 39 +++ .../src/portal/test/TestQueryProvider.tsx | 19 ++ .../src/portal/views/ConnectCallback.css | 31 +- .../src/portal/views/ConnectCallback.test.tsx | 88 +++-- .../src/portal/views/Integrations.test.tsx | 10 + .../editor/src/portal/views/Integrations.tsx | 24 +- .../src/portal/views/Pipelines.gated.test.tsx | 117 +++++++ .../src/portal/views/Pipelines.test.tsx | 10 + .../editor/src/portal/views/Pipelines.tsx | 19 +- frontend/editor/src/portal/views/Policies.tsx | 26 +- .../src/portal/views/Sources.gated.test.tsx | 111 +++++++ .../editor/src/portal/views/Sources.test.tsx | 10 + frontend/editor/src/portal/views/Sources.tsx | 23 +- .../src/portal/views/Users.caching.test.tsx | 10 + .../src/portal/views/Users.gated.test.tsx | 96 ++++++ .../src/portal/views/Users.saas.test.tsx | 10 + frontend/editor/src/portal/views/Users.tsx | 25 +- .../src/saas/routes/ConnectApproveView.tsx | 90 ++++- frontend/editor/src/saas/routes/connect.css | 9 +- 58 files changed, 2346 insertions(+), 575 deletions(-) create mode 100644 frontend/editor/src/portal/components/ConnectAccountRail.css create mode 100644 frontend/editor/src/portal/components/ConnectAccountRail.stories.tsx create mode 100644 frontend/editor/src/portal/components/ConnectAccountRail.tsx create mode 100644 frontend/editor/src/portal/components/account-link/ConnectGuardedRoute.tsx delete mode 100644 frontend/editor/src/portal/components/account-link/LinkAccountModal.css delete mode 100644 frontend/editor/src/portal/components/account-link/LinkGate.tsx create mode 100644 frontend/editor/src/portal/components/account-link/connect/ConnectAskStep.tsx create mode 100644 frontend/editor/src/portal/components/account-link/connect/ConnectBenefitsSlide.stories.tsx create mode 100644 frontend/editor/src/portal/components/account-link/connect/ConnectBenefitsSlide.tsx create mode 100644 frontend/editor/src/portal/components/account-link/connect/ConnectDoneSlide.test.tsx create mode 100644 frontend/editor/src/portal/components/account-link/connect/ConnectDoneSlide.tsx create mode 100644 frontend/editor/src/portal/components/account-link/connect/ConnectHandoffGhost.tsx create mode 100644 frontend/editor/src/portal/components/account-link/connect/connect.css delete mode 100644 frontend/editor/src/portal/components/billing/LinkAccountPrompt.stories.tsx delete mode 100644 frontend/editor/src/portal/components/billing/LinkAccountPrompt.tsx create mode 100644 frontend/editor/src/portal/hooks/useConnectGate.test.tsx create mode 100644 frontend/editor/src/portal/hooks/useConnectGate.ts create mode 100644 frontend/editor/src/portal/hooks/useConnectHandoff.ts create mode 100644 frontend/editor/src/portal/hooks/useConnectPrompt.test.tsx create mode 100644 frontend/editor/src/portal/hooks/useConnectPrompt.ts create mode 100644 frontend/editor/src/portal/hooks/useDevConnectBypass.test.tsx create mode 100644 frontend/editor/src/portal/hooks/useDevConnectBypass.ts create mode 100644 frontend/editor/src/portal/views/Pipelines.gated.test.tsx create mode 100644 frontend/editor/src/portal/views/Sources.gated.test.tsx create mode 100644 frontend/editor/src/portal/views/Users.gated.test.tsx diff --git a/app/core/src/main/java/stirling/software/SPDF/controller/api/misc/ConfigController.java b/app/core/src/main/java/stirling/software/SPDF/controller/api/misc/ConfigController.java index 36beb6610c..618d5d642d 100644 --- a/app/core/src/main/java/stirling/software/SPDF/controller/api/misc/ConfigController.java +++ b/app/core/src/main/java/stirling/software/SPDF/controller/api/misc/ConfigController.java @@ -338,6 +338,19 @@ public class ConfigController { // Premium/Enterprise settings configData.put("premiumEnabled", applicationProperties.getPremium().isEnabled()); + // Whether this instance can link a Stirling (SaaS) account at all. The account-link + // beans live in :proprietary and are @ConditionalOnProperty on this same key, so when + // it is off they are absent and /api/v1/account-link/* returns 404. The frontend cannot + // tell that 404 apart from "not linked yet", so it needs this told to it explicitly + // before it can prompt anyone to link. Read from the environment rather than + // AccountLinkProperties because :core must not depend on :proprietary. + configData.put( + "accountLinkAvailable", + applicationContext + .getEnvironment() + .getProperty( + "stirling.billing.account-link.enabled", Boolean.class, false)); + // AI Engine settings ApplicationProperties.AiEngine aiEngineConfig = applicationProperties.getAiEngine(); configData.put("aiEngineEnabled", aiEngineConfig.isEnabled()); diff --git a/frontend/editor/public/locales/en-US/translation.toml b/frontend/editor/public/locales/en-US/translation.toml index baee16cd02..df996b23c0 100644 --- a/frontend/editor/public/locales/en-US/translation.toml +++ b/frontend/editor/public/locales/en-US/translation.toml @@ -3641,6 +3641,7 @@ system = "System Configuration" [connect] loading = "Checking this request." redirecting = "Returning you to your server." +step = "Step {{current}} of {{total}}" [connect.confirm] acknowledge = "I recognise this address and want to connect it to my team" @@ -6804,10 +6805,22 @@ after = "to enable account linking against the hosted Stirling account. In dev y before = "Set" title = "SaaS login not configured" +[portal.accountLink.connect] +close = "Close" +notNow = "Not now" +start = "Connect Stirling account" +step = "Step {{current}} of {{total}}" + +[portal.accountLink.connect.benefits] +creditsDetail = "500 free per month" +creditsLabel = "Credits" +processorDetail = "Pipelines, policies, sources and audit" +processorLabel = "Processor" +teamsDetail = "Free for up to 5 users" +teamsLabel = "Teams" + [portal.accountLink.connect.callback] -continue = "Continue" linkedNotSignedIn = "You are not signed in to Stirling in this browser, so usage and billing will ask you to sign in." -modalTitle = "Connecting this server" retry = "Try again" signedInAnyway = "You are signed in to Stirling, so billing and usage will load. Only the server link is incomplete." working = "Finishing the connection." @@ -6816,10 +6829,6 @@ working = "Finishing the connection." body = "Connection requests are short lived. Start another one." title = "Request expired" -[portal.accountLink.connect.callback.linked] -body = "This server is connected to your Stirling account." -title = "Server connected" - [portal.accountLink.connect.callback.malformed] body = "This page was opened without a valid connection response. Start the connection from settings." title = "Could not read the response" @@ -6832,11 +6841,23 @@ title = "Connection not completed" body = "Stirling did not confirm the connection. This is usually temporary." title = "Not finished yet" -[portal.accountLink.gate] -action = "Link account" -description = "Link this org's Stirling account to use billable features." -title = "Link to unlock" -titleFeature = "Link to unlock {{feature}}" +[portal.accountLink.connect.done] +accountLabel = "Account" +addPolicy = "Add a policy" +buildPipeline = "Set up a pipeline" +creditsBarLabel = "Free credits remaining" +creditsSuffix = "of {{allowance}} free credits left" +cta = "Done" +inviteTeam = "Invite your team" +lede = "This server now runs against your Stirling account." +pendingTitle = "Almost there" +switchOnProcessor = "Switch on the Processor" +title = "Connected" + +[portal.accountLink.connect.handoff] +going = "Taking you to stirling.com" +reauthLede = "Your Stirling session expired. Signing in again keeps usage and billing visible. This server stays connected either way." +title = "Connecting" [portal.accountLink.instances] active = "Active" @@ -6866,17 +6887,11 @@ never = "never" [portal.accountLink.modal] cancel = "Cancel" -continueLink = "Continue to Stirling" continueReauth = "Sign in again" -linkSubtitle = "Connect this server to the Stirling account it should bill against." linkTitle = "Connect your Stirling account" noAuthorizeUrl = "Stirling did not return somewhere to continue. Try again in a moment." -reauthSubtitle = "Your Stirling session expired. Sign in again to keep seeing usage and billing. This server stays connected either way." reauthTitle = "Sign in again" startFailed = "Could not reach Stirling to start the connection. Check this server's outbound network access, then try again." -step1 = "We send you to stirling.com to sign in. Any sign-in method works there, including Google and single sign-on." -step2 = "You check this server's address and approve it. A team owner has to do this the first time." -step3 = "Stirling brings you straight back here and finishes up." [portal.accountLink.modal.loginNotConfigured] after = "so this server can finish the connection when you come back." @@ -6895,6 +6910,12 @@ forbidden = "Only the team owner can view the org's linked instances." generic = "Couldn't load the team's linked instances. Try again in a moment." title = "Couldn't load linked instances" +[portal.accountLink.rail] +cta = "Connect" +later = "Not now" +sub = "Unlocks teams, PDF processor, pipelines, and policies. PDF editing stays free." +title = "Connect your Stirling account" + [portal.accountLink.state] free = "Editor plan" subscribed = "Processor plan" @@ -7018,11 +7039,6 @@ title = "Invoice history" viewAriaLabel = "View invoice {{number}} in Stripe" viewLink = "View ↗" -[portal.billing.linkPrompt] -cta = "Link Stirling account" -description = "Manual PDF editing — view, sign, merge, split, watermark, compress, convert, manual OCR — is always free, linked or not. Link to claim 500 free PDFs of metered processing (automation, AI, and the API); when you need more, turn on the Processor plan and only pay for what you use." -title = "Link your Stirling account" - [portal.billing.paymentMethod] billedMonthly = "Billed monthly" cardEnding = "{{brand}} ending {{last4}}" @@ -7178,8 +7194,8 @@ label = "Projected to exceed." [portal.billing.spendThisMonth] eyebrow = "Spend this month" -freeRemaining_one = "{{formatted}} free PDF remaining" -freeRemaining_other = "{{formatted}} free PDFs remaining" +freeRemaining_one = "{{formatted}} free credit remaining" +freeRemaining_other = "{{formatted}} free credits remaining" processed_one = "{{formattedCount}} PDF processed." processed_other = "{{formattedCount}} PDFs processed." processedWithRate_one = "{{formattedCount}} PDF processed, at {{rate}} each." @@ -7203,10 +7219,10 @@ eyebrow = "Processor trial" statusLabel_one = "{{used}} used" statusLabel_other = "{{used}} used" sub = "Use the PDF Editor for free. Pay to process PDFs automatically." -title_one = "Process {{allowance}} PDFs free" -title_other = "Process {{allowance}} PDFs free" -titleWithRate_one = "Process {{allowance}} PDFs free, then {{rate}}/PDF" -titleWithRate_other = "Process {{allowance}} PDFs free, then {{rate}}/PDF" +title_one = "{{allowance}} free credit to start" +title_other = "{{allowance}} free credits to start" +titleWithRate_one = "{{allowance}} free credit, then {{rate}} per PDF" +titleWithRate_other = "{{allowance}} free credits, then {{rate}} per PDF" [portal.components.billingUnit] approval = "approval" diff --git a/frontend/editor/src/core/types/appConfig.ts b/frontend/editor/src/core/types/appConfig.ts index 2dafb3d07a..ef31ed4f4e 100644 --- a/frontend/editor/src/core/types/appConfig.ts +++ b/frontend/editor/src/core/types/appConfig.ts @@ -22,6 +22,12 @@ export interface AppConfig { premiumEnabled?: boolean; premiumKey?: string; paygEnabled?: boolean; + /** + * Whether this instance can link a Stirling (SaaS) account. False means the account-link + * endpoints are absent (404), which is indistinguishable from "not linked" on the client, so + * anything that prompts to link must gate on this first. + */ + accountLinkAvailable?: boolean; termsAndConditions?: string; privacyPolicy?: string; cookiePolicy?: string; diff --git a/frontend/editor/src/portal/PortalProviders.tsx b/frontend/editor/src/portal/PortalProviders.tsx index a82f1c7405..5008dd8537 100644 --- a/frontend/editor/src/portal/PortalProviders.tsx +++ b/frontend/editor/src/portal/PortalProviders.tsx @@ -5,15 +5,24 @@ import { LinkAccountModal } from "@portal/components/account-link/LinkAccountMod import { AccountLinkProvider } from "@portal/contexts/AccountLinkContext"; import { ConnectCallbackHost } from "@portal/components/account-link/ConnectCallbackHost"; import { PortalChrome } from "@portal/components/PortalChrome"; +import { useConnectPrompt } from "@portal/hooks/useConnectPrompt"; -/** The one and only account-link modal. */ +/** The one and only account-link modal, whichever step it is on. */ function LinkModalHost() { - const { linkModalOpen, linkModalMode, closeLinkModal } = useUI(); + const { linkModalOpen, linkModalMode, closeLinkModal, connectOutcome } = + useUI(); + // Ask once a session while the instance is unlinked, rather than waiting to be found. + useConnectPrompt(); + + // Mounted only while open, so closing discards the flow. Kept mounted, an interrupted hand-off + // stays flagged and every later open resumes on the ghost step with no way forward. + if (!linkModalOpen) return null; return ( ); } diff --git a/frontend/editor/src/portal/ViewRouter.tsx b/frontend/editor/src/portal/ViewRouter.tsx index 8e77c20830..c7b87f7388 100644 --- a/frontend/editor/src/portal/ViewRouter.tsx +++ b/frontend/editor/src/portal/ViewRouter.tsx @@ -11,6 +11,7 @@ import { Policies } from "@portal/views/Policies"; import { EditorAdmin } from "@portal/views/EditorAdmin"; import { Infrastructure } from "@portal/views/Infrastructure"; import { PortalBillingGate } from "@portal/components/billing/PortalBillingGate"; +import { ConnectGuardedRoute } from "@portal/components/account-link/ConnectGuardedRoute"; import { VIEW_PATHS, toPortalPath } from "@portal/contexts/ViewContext"; // Lazy so the generated docs manifest (bundled JSON) lands in its own chunk. @@ -32,13 +33,24 @@ export function ViewRouter() { } /> } /> } /> + {/* Building and editing need a linked account. Gated at the route so every way in is + covered: the list, the Documents review queue, the Connect flow's next steps, and a + typed URL. */} } + element={ + + + + } /> } + element={ + + + + } /> } /> {/* Source create/edit is a modal on the list now; old deep links land there. */} diff --git a/frontend/editor/src/portal/components/ConnectAccountRail.css b/frontend/editor/src/portal/components/ConnectAccountRail.css new file mode 100644 index 0000000000..9f4ad4a77e --- /dev/null +++ b/frontend/editor/src/portal/components/ConnectAccountRail.css @@ -0,0 +1,53 @@ +/* ──────────────────────────────────────────────────────────────────────── */ +/* Connect rail — the ambient prompt above Home's deployment card */ +/* ──────────────────────────────────────────────────────────────────────── */ + +/* Flat and quiet: it sits above the hero every visit until the account is + connected, so it has to be legible without competing with the card below it. */ + +.portal-connect-rail { + display: flex; + align-items: center; + gap: 1rem; + padding: 0.875rem 1rem; + margin-bottom: 0.75rem; + border: 1px solid var(--c-border); + border-radius: 0.5rem; + background: var(--c-surface); +} + +.portal-connect-rail__text { + display: flex; + flex-direction: column; + min-width: 0; +} + +.portal-connect-rail__title { + font-size: 0.875rem; + font-weight: 600; + color: var(--c-text); +} + +.portal-connect-rail__sub { + font-size: 0.8125rem; + color: var(--c-text-muted); +} + +.portal-connect-rail__actions { + display: flex; + align-items: center; + gap: 0.5rem; + margin-left: auto; + flex: none; +} + +@media (max-width: 40rem) { + .portal-connect-rail { + flex-direction: column; + align-items: flex-start; + } + + .portal-connect-rail__actions { + margin-left: 0; + } +} diff --git a/frontend/editor/src/portal/components/ConnectAccountRail.stories.tsx b/frontend/editor/src/portal/components/ConnectAccountRail.stories.tsx new file mode 100644 index 0000000000..c813db9b3c --- /dev/null +++ b/frontend/editor/src/portal/components/ConnectAccountRail.stories.tsx @@ -0,0 +1,46 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { http, HttpResponse } from "msw"; +import { LinkProvider, type LinkState } from "@portal/contexts/LinkContext"; +import { ConnectAccountRail } from "@portal/components/ConnectAccountRail"; + +/** + * Both halves of "can link but has not" come from outside the component, and the global mock answers + * app-config without the flag — so a story that wants the rail has to ask for it. + */ +const canLink = { + msw: { + handlers: [ + http.get("/api/v1/config/app-config", () => + HttpResponse.json({ accountLinkAvailable: true }), + ), + ], + }, +}; + +const withLinkState = (state: LinkState) => [ + (Story: () => React.JSX.Element) => ( + + + + ), +]; + +const meta: Meta = { + title: "Portal/AccountLink/ConnectAccountRail", + component: ConnectAccountRail, + parameters: { layout: "padded" }, +}; +export default meta; +type Story = StoryObj; + +/** On Home: the ask, plus a way to defer it for this session. */ +export const Default: Story = { + parameters: canLink, + decorators: withLinkState("unlinked"), +}; + +/** Connected, so the rail removes itself. Renders nothing on purpose. */ +export const Hidden: Story = { + parameters: canLink, + decorators: withLinkState("linked-free"), +}; diff --git a/frontend/editor/src/portal/components/ConnectAccountRail.tsx b/frontend/editor/src/portal/components/ConnectAccountRail.tsx new file mode 100644 index 0000000000..9f1e09c0ce --- /dev/null +++ b/frontend/editor/src/portal/components/ConnectAccountRail.tsx @@ -0,0 +1,57 @@ +import { useState } from "react"; +import { useTranslation } from "react-i18next"; +import { Button } from "@app/ui"; +import { useConnectGate } from "@portal/hooks/useConnectGate"; +import "@portal/components/ConnectAccountRail.css"; + +const DISMISSED_KEY = "portal::connect-rail-dismissed"; + +function readDismissed(): boolean { + try { + return sessionStorage.getItem(DISMISSED_KEY) === "true"; + } catch { + return false; + } +} + +/** Session-scoped dismissal, so the ask comes back until it is answered rather than for good. */ +export function ConnectAccountRail() { + const { t } = useTranslation(); + const { gated, loading, connect } = useConnectGate(); + const [dismissed, setDismissed] = useState(readDismissed); + + if (loading || !gated || dismissed) return null; + + const dismiss = () => { + try { + sessionStorage.setItem(DISMISSED_KEY, "true"); + } catch { + // Storage refusing is no reason to leave the rail stuck on screen. + } + setDismissed(true); + }; + + return ( +
+
+ + {t("portal.accountLink.rail.title", "Connect your Stirling account")} + + + {t( + "portal.accountLink.rail.sub", + "Unlocks teams, PDF processor, pipelines, and policies. PDF editing stays free.", + )} + +
+
+ + +
+
+ ); +} diff --git a/frontend/editor/src/portal/components/HomeHero.tsx b/frontend/editor/src/portal/components/HomeHero.tsx index 954eac9908..f2fdd36b51 100644 --- a/frontend/editor/src/portal/components/HomeHero.tsx +++ b/frontend/editor/src/portal/components/HomeHero.tsx @@ -2,6 +2,7 @@ import { useEffect } from "react"; import { Skeleton } from "@app/ui"; import { useUI } from "@portal/contexts/UIContext"; import { EditorStatusCard } from "@portal/components/EditorStatusCard"; +import { ConnectAccountRail } from "@portal/components/ConnectAccountRail"; import { ControlledDealStatusHero } from "@portal/components/procurement/ProcurementBanner"; import { ProcurementFlow } from "@portal/components/procurement/ProcurementFlow"; import { useProcurement } from "@portal/components/procurement/useProcurement"; @@ -28,6 +29,7 @@ export function HomeHero() { return ( <> + {procurement.loading ? ( // Hold the rail's shape rather than committing to a footer: branching before the snapshot // lands paints the no-deal rail first, flashing on every refresh of an active deal. diff --git a/frontend/editor/src/portal/components/Sidebar.tsx b/frontend/editor/src/portal/components/Sidebar.tsx index 2895ad4ec4..6d4cad1945 100644 --- a/frontend/editor/src/portal/components/Sidebar.tsx +++ b/frontend/editor/src/portal/components/Sidebar.tsx @@ -11,6 +11,7 @@ import { useTranslation } from "react-i18next"; import { useView, type ViewId } from "@portal/contexts/ViewContext"; import { useUI } from "@portal/contexts/UIContext"; import { LinkAccountFooterItem } from "@portal/components/LinkAccountFooterItem"; +import { useConnectGate } from "@portal/hooks/useConnectGate"; import { CloseIcon } from "@portal/components/icons"; import { GROUP_PROCESSOR, @@ -43,6 +44,7 @@ export function Sidebar() { const { displayName, profilePictureUrl } = useAccountIdentity(); const credits = useFreeCreditsSummary(); const openPlan = useOpenPlan(); + const { gated, connect } = useConnectGate(); // Collapse is a desktop-only affordance: on mobile the sidebar is an // off-canvas drawer, so the icon-rail state never applies there. @@ -67,6 +69,9 @@ export function Sidebar() { closeMobileNav(); if (entry.externalUrl) { window.open(entry.externalUrl, "_blank", "noopener,noreferrer"); + } else if (entry.requiresLink && gated) { + // Ask here: navigating first strands them on a page with nothing on it. + connect(); } else { setActiveView(id as ViewId); } diff --git a/frontend/editor/src/portal/components/account-link/ConnectCallbackHost.tsx b/frontend/editor/src/portal/components/account-link/ConnectCallbackHost.tsx index fab501da4a..2fcbfdea0f 100644 --- a/frontend/editor/src/portal/components/account-link/ConnectCallbackHost.tsx +++ b/frontend/editor/src/portal/components/account-link/ConnectCallbackHost.tsx @@ -1,21 +1,10 @@ -import { useCallback, useEffect, useRef, useState } from "react"; -import { useLocation, useNavigate } from "react-router-dom"; -import { useTranslation } from "react-i18next"; -import { Modal } from "@app/ui"; -import { PORTAL_BASENAME } from "@app/routes/portalBasename"; -import { withBasePath } from "@app/constants/app"; -import { - completeConnect, - startConnect, - type ConnectPhase, -} from "@portal/api/link"; +import { useEffect, useRef } from "react"; +import { useLocation } from "react-router-dom"; +import { completeConnect, type ConnectPhase } from "@portal/api/link"; import { ensureSaasSupabase } from "@portal/auth/saasSupabase"; import { useAccountLinkContext } from "@portal/contexts/AccountLinkContext"; -import { - ConnectCallbackView, - type ConnectCallbackState, -} from "@portal/components/account-link/ConnectCallbackView"; -import "@portal/views/ConnectCallback.css"; +import { useUI } from "@portal/contexts/UIContext"; +import type { ConnectCallbackState } from "@portal/components/account-link/ConnectCallbackView"; /** What the callback route hands over, read from the URL fragment before stripping it. */ export interface AccountLinkReturn { @@ -30,41 +19,23 @@ interface LocationState { } /** - * Finishes the handshake and reports the outcome, over the portal the admin - * started from. - * - * Mounted alongside the other portal-wide modal rather than being its own route: - * the result is a step in a task, so the page behind it should still be there. + * Renders nothing: the result belongs on step 3 of the dialog the admin left, so this publishes the + * outcome and the single dialog host reopens there. Mounted app-wide because the callback route + * only reads the fragment and navigates, so it is gone by the time there is an outcome. */ export function ConnectCallbackHost() { const location = useLocation(); - const navigate = useNavigate(); - const { t } = useTranslation(); const { refresh } = useAccountLinkContext(); + const { publishConnectOutcome } = useUI(); const handover = (location.state as LocationState | null)?.accountLinkReturn; - const [state, setState] = useState(null); - const [sessionRestored, setSessionRestored] = useState(false); - const nonceRef = useRef(null); const startedRef = useRef(false); - const finish = useCallback( - async (nonce: string) => { - setState("working"); - try { - const outcome = toViewState((await completeConnect(nonce)).phase); - setState(outcome); - // The portal read its status on mount, before this existed. Without this - // the page behind the modal still says unlinked until a reload. - if (outcome === "linked") await refresh(); - } catch { - // Could not reach our own backend. The handshake is still open, so this - // is worth another attempt rather than a restart. - setState("retry"); - } - }, - [refresh], - ); + // Refs so the effect runs on the hand-over alone: it consumes a single-use nonce. + const publishRef = useRef(publishConnectOutcome); + publishRef.current = publishConnectOutcome; + const refreshRef = useRef(refresh); + refreshRef.current = refresh; useEffect(() => { if (!handover || startedRef.current) return; @@ -72,17 +43,16 @@ export function ConnectCallbackHost() { const { type, nonce, accessToken, refreshToken } = handover; if (type !== "link" || !nonce) { - setState("malformed"); + publishRef.current({ state: "malformed", sessionRestored: false }); return; } - nonceRef.current = nonce; void (async () => { + let sessionRestored = false; if (accessToken && refreshToken) { try { const supabase = ensureSaasSupabase(); - // Logged, not swallowed: silently this resurfaces later as "session - // expired" on the usage page, with nothing tying it back here. + // Logged, not swallowed: this resurfaces later as "session expired" otherwise. if (!supabase) { console.warn( "[account-link] no Supabase client: VITE_SUPABASE_URL / VITE_SUPABASE_PUBLISHABLE_DEFAULT_KEY are not set for this build", @@ -95,7 +65,7 @@ export function ConnectCallbackHost() { if (error) { console.warn("[account-link] setSession failed:", error.message); } else { - setSessionRestored(true); + sessionRestored = true; } } } catch (e) { @@ -106,68 +76,36 @@ export function ConnectCallbackHost() { "[account-link] callback carried no tokens; the approval page had no session to pass", ); } - await finish(nonce); + + await claim(nonce, sessionRestored); })(); - }, [handover, finish]); - /** - * Retry means different things either side of a still-valid handshake: finish the one we have, or open a new one when it is past saving. - */ - const onRetry = useCallback(() => { - if (state === "retry" && nonceRef.current) { - void finish(nonceRef.current); - return; + /** Passes itself as {@code reclaim} so Try again re-claims rather than opening a handshake. */ + async function claim(nonce: string, sessionRestored: boolean) { + publishRef.current({ state: "working", sessionRestored }); + const again = () => void claim(nonce, sessionRestored); + try { + const state = toViewState((await completeConnect(nonce)).phase); + publishRef.current({ + state, + sessionRestored, + reclaim: state === "retry" ? again : undefined, + }); + // Without this the page behind the dialog says unlinked until a reload. + if (state === "linked") await refreshRef.current(); + } catch { + // Our own backend is unreachable; the handshake is untouched, so retrying beats restarting. + publishRef.current({ state: "retry", sessionRestored, reclaim: again }); + } } - setState("working"); - // Same callback the modal sends. Without it the backend falls back to the bare - // origin, which drops the app's base path and lands the return on nothing. - void startConnect( - window.location.hostname, - new URL( - withBasePath("/account-link/callback"), - window.location.origin, - ).toString(), - ) - .then((status) => { - if (status.authorizeUrl) { - window.location.assign(status.authorizeUrl); - } else { - setState("rejected"); - } - }) - .catch(() => setState("retry")); - }, [state, finish]); + }, [handover]); - // Drops the handover with it, so a back navigation does not reopen the result. - const done = useCallback(() => { - setState(null); - navigate(PORTAL_BASENAME, { replace: true }); - }, [navigate]); - - if (!state) return null; - - return ( - - - - ); + return null; } /** - * PENDING and UNAVAILABLE collapse into one "try again" state: both mean the handshake is intact but unfinished, which is the same thing to do about it. + * PENDING and UNAVAILABLE collapse into one "try again" state: both mean the handshake is intact but + * unfinished, which is the same thing to do about it. */ function toViewState(phase: ConnectPhase): ConnectCallbackState { switch (phase) { diff --git a/frontend/editor/src/portal/components/account-link/ConnectCallbackView.tsx b/frontend/editor/src/portal/components/account-link/ConnectCallbackView.tsx index 4a22592e45..ae9e268849 100644 --- a/frontend/editor/src/portal/components/account-link/ConnectCallbackView.tsx +++ b/frontend/editor/src/portal/components/account-link/ConnectCallbackView.tsx @@ -1,5 +1,7 @@ import { useTranslation } from "react-i18next"; -import { Banner, Button, Spinner } from "@app/ui"; +import { Banner, Spinner } from "@app/ui"; +import { ConnectDoneSlide } from "@portal/components/account-link/connect/ConnectDoneSlide"; +import "@portal/components/account-link/connect/connect.css"; /** Outcomes of returning from the approval page. */ export type ConnectCallbackState = @@ -10,26 +12,39 @@ export type ConnectCallbackState = | "rejected" | "malformed"; -export interface ConnectCallbackViewProps { +export interface ConnectOutcome { state: ConnectCallbackState; /** True once the SaaS session landed, regardless of how the link itself went. */ sessionRestored: boolean; - onRetry: () => void; + /** Present only while the handshake is still open, so a retry re-claims rather than opening one. */ + reclaim?: () => void; +} + +export function isRetryableOutcome(state: ConnectCallbackState): boolean { + return state !== "linked" && state !== "malformed"; +} + +export interface ConnectCallbackViewProps { + state: ConnectCallbackState; + sessionRestored: boolean; onDone: () => void; } -/** Presentation for the account-link callback. */ +/** + * Five states in one step: a failed link is still step 3 of the flow they started, and a separate + * error dialog would discard the progress bar that says where they are. Actions live in the + * dialog's footer, not here, so they stay where steps 1 and 2 put them. + */ export function ConnectCallbackView({ state, sessionRestored, - onRetry, onDone, }: ConnectCallbackViewProps) { const { t } = useTranslation(); if (state === "working") { return ( -
+

{t( @@ -44,21 +59,14 @@ export function ConnectCallbackView({ if (state === "linked") { return (

- +

{t( - "portal.accountLink.connect.callback.linked.body", - "This server is connected to your Stirling account.", + "portal.accountLink.connect.done.lede", + "This server now runs against your Stirling account.", )} - - {/* The inverse of the failure note below: the link took but the sign-in did - not, which otherwise only shows up later as "session expired" on a page - that gives no hint the two are related. */} +

+ {/* Link took, sign-in did not: otherwise this resurfaces later as "session expired" with + nothing tying it back here. */} {sessionRestored ? null : (

{t( @@ -67,22 +75,19 @@ export function ConnectCallbackView({ )}

)} - +
); } - const { tone, title, body, retryable } = failure(state, t); + const { tone, title, body } = failure(state, t); return (
{body} - {/* The SaaS sign-in and the server link are separate outcomes. Say so when - one worked and the other did not, or the admin re-runs the whole thing - to fix a problem that is already half solved. */} + {/* Two separate outcomes: without this the admin re-runs the lot to fix a half-solved + problem. */} {sessionRestored ? (

{t( @@ -91,11 +96,6 @@ export function ConnectCallbackView({ )}

) : null} -
); } @@ -115,7 +115,6 @@ function failure(state: ConnectCallbackState, t: Translate) { "portal.accountLink.connect.callback.expired.body", "Connection requests are short lived. Start another one.", ), - retryable: true, }; case "rejected": return { @@ -128,7 +127,6 @@ function failure(state: ConnectCallbackState, t: Translate) { "portal.accountLink.connect.callback.rejected.body", "This request was declined or has already been used. Start another one if that was not intended.", ), - retryable: true, }; case "malformed": return { @@ -141,7 +139,6 @@ function failure(state: ConnectCallbackState, t: Translate) { "portal.accountLink.connect.callback.malformed.body", "This page was opened without a valid connection response. Start the connection from settings.", ), - retryable: false, }; default: return { @@ -156,7 +153,6 @@ function failure(state: ConnectCallbackState, t: Translate) { "portal.accountLink.connect.callback.unfinished.body", "Stirling did not confirm the connection. This is usually temporary.", ), - retryable: true, }; } } diff --git a/frontend/editor/src/portal/components/account-link/ConnectGuardedRoute.tsx b/frontend/editor/src/portal/components/account-link/ConnectGuardedRoute.tsx new file mode 100644 index 0000000000..e5bd45eff9 --- /dev/null +++ b/frontend/editor/src/portal/components/account-link/ConnectGuardedRoute.tsx @@ -0,0 +1,26 @@ +import { useEffect, type ReactNode } from "react"; +import { Navigate } from "react-router-dom"; +import { useConnectGate } from "@portal/hooks/useConnectGate"; + +interface Props { + children: ReactNode; + fallback: string; +} + +/** + * At the route, not on the buttons: the pipeline builder is reachable from its list, the Documents + * queue, the connect flow's next steps and a typed URL, and a guard per entry point is one more to + * remember each time someone adds a link. + */ +export function ConnectGuardedRoute({ children, fallback }: Props) { + const { gated, loading, connect } = useConnectGate(); + + useEffect(() => { + if (gated) connect(); + }, [gated, connect]); + + // Unknown is not gated: bouncing first would throw a linked admin off a page they are entitled to. + if (loading) return null; + if (gated) return ; + return <>{children}; +} diff --git a/frontend/editor/src/portal/components/account-link/LinkAccountModal.css b/frontend/editor/src/portal/components/account-link/LinkAccountModal.css deleted file mode 100644 index bc96172f0a..0000000000 --- a/frontend/editor/src/portal/components/account-link/LinkAccountModal.css +++ /dev/null @@ -1,26 +0,0 @@ -/* Connect-account modal. Imported by the component rather than relying on the - account-link view's stylesheet: this modal is mounted at the app root, so it - renders on pages that never import that view. */ - -.portal-link__modal-body { - display: flex; - flex-direction: column; - gap: 1rem; -} - -.portal-link__steps { - display: flex; - flex-direction: column; - gap: 0.5rem; - margin: 0; - padding-left: 1.25rem; - font-size: 0.875rem; - line-height: 1.5; - color: var(--c-text-muted); -} - -.portal-link__modal-actions { - display: flex; - justify-content: flex-end; - gap: 0.5rem; -} diff --git a/frontend/editor/src/portal/components/account-link/LinkAccountModal.test.tsx b/frontend/editor/src/portal/components/account-link/LinkAccountModal.test.tsx index 018d435b31..515b740e59 100644 --- a/frontend/editor/src/portal/components/account-link/LinkAccountModal.test.tsx +++ b/frontend/editor/src/portal/components/account-link/LinkAccountModal.test.tsx @@ -1,34 +1,66 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; -import { act, render, waitFor } from "@testing-library/react"; -import { MantineProvider } from "@mantine/core"; +import { act, render, screen, waitFor } from "@testing-library/react"; +import { MemoryRouter } from "react-router-dom"; +import { PortalTestProviders } from "@portal/test/TestQueryProvider"; -/** The modal every "link account" CTA in the portal opens. */ -const { startConnect, startReauth } = vi.hoisted(() => ({ +/** The step machine: what drives each step, and what must not skip or repeat one. */ +const { startConnect, startReauth, fetchWallet, EMAIL } = vi.hoisted(() => ({ startConnect: vi.fn(), startReauth: vi.fn(), + fetchWallet: vi.fn(), + EMAIL: "admin@acme.example", })); vi.mock("@portal/api/link", () => ({ startConnect, startReauth })); +vi.mock("@portal/api/billing", () => ({ fetchWallet })); vi.mock("@portal/auth/saasSupabase", () => ({ isSaasSupabaseConfigured: true, + // Step 3 reads the connected account's email off this session. + ensureSaasSupabase: () => ({ + auth: { + getSession: () => + Promise.resolve({ data: { session: { user: { email: EMAIL } } } }), + }, + }), })); import { LinkAccountModal } from "@portal/components/account-link/LinkAccountModal"; +import type { ConnectOutcome } from "@portal/components/account-link/ConnectCallbackView"; +import { freeWallet } from "@portal/components/billing/walletFixtures"; const AUTHORIZE = "http://localhost:5174/link?request=req-1"; -function renderModal(mode?: "link" | "reauth") { +const BENEFITS = "Pipelines, policies, sources and audit"; +const GHOST = /Taking you to stirling\.com/; +const CONNECT = /Connect Stirling account/; + +function renderModal( + mode?: "link" | "reauth", + outcome: ConnectOutcome | null = null, +) { return render( - - {}} mode={mode} /> - , + + + {}} + mode={mode} + outcome={outcome} + /> + + , ); } -/** Clicks the primary action (the secondary one is Cancel). */ -function clickContinue(getAllByRole: (role: string) => HTMLElement[]) { - const buttons = getAllByRole("button"); - act(() => buttons[buttons.length - 1].click()); +function click(label: string | RegExp) { + act(() => screen.getByRole("button", { name: label }).click()); +} + +/** Read off the body because the dialog portals out; the badge itself is uninterpolated here. */ +function filledSteps(): number { + return document.body.querySelectorAll( + ".portal-stepmodal__progress .is-filled", + ).length; } describe("LinkAccountModal", () => { @@ -36,6 +68,7 @@ describe("LinkAccountModal", () => { beforeEach(() => { vi.clearAllMocks(); + fetchWallet.mockResolvedValue(freeWallet); startConnect.mockResolvedValue({ phase: "PENDING", authorizeUrl: AUTHORIZE, @@ -54,28 +87,39 @@ describe("LinkAccountModal", () => { value: { origin: "http://localhost:5173", hostname: "localhost", + href: "http://localhost:5173/app", + search: "", assign, }, }); }); + it("opens on the pitch, and asks nothing of the backend until told", () => { + renderModal(); + + expect(screen.getByText(BENEFITS)).toBeTruthy(); + expect(screen.getByRole("button", { name: CONNECT })).toBeTruthy(); + expect(filledSteps()).toBe(1); + expect(startConnect).not.toHaveBeenCalled(); + }); + it("offers no sign-in form, because a sign-in started here cannot complete", () => { const { container } = renderModal(); + click(CONNECT); - // The provider buttons this modal used to carry sent the admin to Stirling and - // abandoned them there. Nothing should collect credentials on this origin. + // A sign-in started on this origin cannot complete, so nothing here may collect credentials. expect(container.querySelector("input[type=password]")).toBeNull(); expect(container.querySelector("input[type=email]")).toBeNull(); }); - it("starts a link handshake and hands the browser to Stirling", async () => { - const { getAllByRole } = renderModal(); - - clickContinue(getAllByRole); + it("hands over on the first click, showing the ghost while it goes", async () => { + renderModal(); + click(CONNECT); await waitFor(() => expect(startConnect).toHaveBeenCalled()); - // Callback built from this page's own origin, which the backend then checks - // against the request's Origin header. + expect(screen.getByText(GHOST)).toBeTruthy(); + expect(filledSteps()).toBe(2); + // The backend checks this against the request's Origin header. expect(startConnect).toHaveBeenCalledWith( "localhost", "http://localhost:5173/account-link/callback", @@ -84,13 +128,16 @@ describe("LinkAccountModal", () => { expect(startReauth).not.toHaveBeenCalled(); }); - it("uses the reauth endpoint when only the session needs renewing", async () => { - const { getAllByRole } = renderModal("reauth"); + it("uses the reauth endpoint, with no pitch and no steps", async () => { + renderModal("reauth"); - clickContinue(getAllByRole); + // A server that is already connected is not sold anything. + expect(screen.queryByText(BENEFITS)).toBeNull(); + expect(screen.queryByText(/Step 1 of 3/)).toBeNull(); - // A different endpoint on purpose: reauth presents the device credential so - // Stirling pins the handshake to the team that already owns this server. + click(/Sign in again/); + + // A different endpoint: reauth presents the credential, so the team is pinned server-side. await waitFor(() => expect(startReauth).toHaveBeenCalledWith( "http://localhost:5173/account-link/callback", @@ -100,14 +147,18 @@ describe("LinkAccountModal", () => { await waitFor(() => expect(assign).toHaveBeenCalledWith(AUTHORIZE)); }); - it("stays put and explains itself when the handshake cannot start", async () => { + it("falls back to step 1 with the reason when the handshake cannot start", async () => { startConnect.mockRejectedValue(new Error("offline")); - const { getAllByRole } = renderModal(); - clickContinue(getAllByRole); + renderModal(); + click(CONNECT); await waitFor(() => expect(startConnect).toHaveBeenCalled()); expect(assign).not.toHaveBeenCalled(); + // The ghost unmounts when the request settles, so the reason lands on step 1. + expect(await screen.findByText(/outbound network access/)).toBeTruthy(); + expect(screen.getByText(BENEFITS)).toBeTruthy(); + expect(filledSteps()).toBe(1); }); it("does not navigate when there is nothing to navigate to", async () => { @@ -119,10 +170,85 @@ describe("LinkAccountModal", () => { teamId: 7, }); - const { getAllByRole } = renderModal(); - clickContinue(getAllByRole); + renderModal(); + click(CONNECT); await waitFor(() => expect(startConnect).toHaveBeenCalled()); expect(assign).not.toHaveBeenCalled(); }); + + /** Busy is never cleared on success, because the page was meant to be gone. */ + describe("coming back from a hand-off that never completed", () => { + it("clears the in-flight flag when the page is shown again", async () => { + renderModal(); + click(CONNECT); + + await waitFor(() => expect(screen.getByText(GHOST)).toBeTruthy()); + + act(() => { + window.dispatchEvent(new Event("pageshow")); + }); + + expect(screen.getByText(BENEFITS)).toBeTruthy(); + expect(filledSteps()).toBe(1); + }); + + /** + * Not the close path itself (the host unmounts, so a fresh mount is clean by construction) but + * the property behind it: hoist the flag into UIContext and the trap returns, failing here. + */ + it("keeps the in-flight flag local, so a fresh mount cannot inherit one", async () => { + const first = renderModal(); + click(CONNECT); + await waitFor(() => expect(screen.getByText(GHOST)).toBeTruthy()); + + first.unmount(); + renderModal(); + + expect(screen.getByText(BENEFITS)).toBeTruthy(); + expect(screen.queryByText(GHOST)).toBeNull(); + }); + }); + + describe("resuming after the round trip", () => { + it("lands on step 3 rather than restarting the pitch", async () => { + renderModal("link", { state: "linked", sessionRestored: true }); + + expect( + await screen.findByText(/now runs against your Stirling account/), + ).toBeTruthy(); + expect(screen.queryByText(BENEFITS)).toBeNull(); + // Left on 2 of 3, arrived on 3: the whole reason the bar spans the redirect. + expect(filledSteps()).toBe(3); + expect(await screen.findByText(EMAIL)).toBeTruthy(); + expect(await screen.findByText("Invite your team")).toBeTruthy(); + }); + + it("opens a fresh handshake for a spent one, showing the ghost again", async () => { + renderModal("link", { state: "expired", sessionRestored: false }); + + expect(await screen.findByText("Request expired")).toBeTruthy(); + click(/Try again/); + + await waitFor(() => expect(startConnect).toHaveBeenCalled()); + // Busy outranks the stale outcome, or they sit on "Request expired" until the browser goes. + expect(screen.getByText(GHOST)).toBeTruthy(); + }); + + it("offers no retry while the claim is still in flight", async () => { + renderModal("link", { state: "working", sessionRestored: false }); + + expect(await screen.findByText(/Finishing the connection/)).toBeTruthy(); + expect(screen.queryByRole("button", { name: /Try again/ })).toBeNull(); + }); + + it("does not offer a retry for a response it could not read", async () => { + renderModal("link", { state: "malformed", sessionRestored: false }); + + expect( + await screen.findByText(/Could not read the response/), + ).toBeTruthy(); + expect(screen.queryByRole("button", { name: /Try again/ })).toBeNull(); + }); + }); }); diff --git a/frontend/editor/src/portal/components/account-link/LinkAccountModal.tsx b/frontend/editor/src/portal/components/account-link/LinkAccountModal.tsx index 8bd47a9eb4..2b6315eb17 100644 --- a/frontend/editor/src/portal/components/account-link/LinkAccountModal.tsx +++ b/frontend/editor/src/portal/components/account-link/LinkAccountModal.tsx @@ -1,143 +1,205 @@ -import { useCallback, useState } from "react"; import { useTranslation } from "react-i18next"; -import { Banner, Button, Modal } from "@app/ui"; -import { withBasePath } from "@app/constants/app"; -import { startConnect, startReauth } from "@portal/api/link"; -import { isSaasSupabaseConfigured } from "@portal/auth/saasSupabase"; -import "@portal/components/account-link/LinkAccountModal.css"; +import { Button } from "@app/ui"; +import { FlowModal } from "@portal/components/shared/FlowModal"; +import { StepModalHeader } from "@portal/components/shared/StepModalHeader"; +import { ConnectAskStep } from "@portal/components/account-link/connect/ConnectAskStep"; +import { ConnectHandoffGhost } from "@portal/components/account-link/connect/ConnectHandoffGhost"; +import { + ConnectCallbackView, + isRetryableOutcome, + type ConnectOutcome, +} from "@portal/components/account-link/ConnectCallbackView"; +import { useConnectHandoff } from "@portal/hooks/useConnectHandoff"; +import "@portal/views/ConnectCallback.css"; + +/** + * Ordered, so a step's position in this list is its number and the list's length is the total. + * Adding or removing a step means editing this and its arm of `stepBody`, nothing else. + */ +const STEP_ORDER = ["ask", "handoff", "outcome"] as const; + +type StepId = (typeof STEP_ORDER)[number]; interface Props { open: boolean; onClose: () => void; - /** - * "link" connects this server to a team for the first time; "reauth" only re-establishes the browser's Stirling session for a server that is already linked. - */ + /** "reauth" only re-establishes the browser session, so it stays one step with no pitch. */ mode?: "link" | "reauth"; + /** Published by the callback route; present means the admin is returning from Stirling. */ + outcome?: ConnectOutcome | null; } -/** Sends the admin off to Stirling to connect this server. */ -export function LinkAccountModal({ open, onClose, mode = "link" }: Props) { +/** + * The progress bar spans the redirect on purpose: the admin leaves on the hand-off and returns on + * the outcome step of the dialog they left, rather than being greeted by a different one. + */ +export function LinkAccountModal({ + open, + onClose, + mode = "link", + outcome = null, +}: Props) { const { t } = useTranslation(); const reauth = mode === "reauth"; - const [busy, setBusy] = useState(false); - const [error, setError] = useState(null); + const handoff = useConnectHandoff(reauth); - const begin = useCallback(async () => { - setBusy(true); - setError(null); - try { - const callbackUrl = new URL( - withBasePath("/account-link/callback"), - window.location.origin, - ).toString(); - const status = reauth - ? await startReauth(callbackUrl) - : await startConnect(window.location.hostname, callbackUrl); - if (status.authorizeUrl) { - window.location.assign(status.authorizeUrl); - return; - } - // Already linked, or a handshake we cannot act on. Nothing to navigate to. - setError( - t( - "portal.accountLink.modal.noAuthorizeUrl", - "Stirling did not return somewhere to continue. Try again in a moment.", + // Busy outranks a stale outcome, or a retry sits on the old result until the browser leaves. + let step: StepId = "ask"; + if (handoff.busy) step = "handoff"; + else if (outcome) step = "outcome"; + + const title = stepTitle(); + const current = STEP_ORDER.indexOf(step) + 1; + + // Re-auth is one step, so it carries no count and no progress bar. + const stepChrome = reauth + ? {} + : { + step: current, + total: STEP_ORDER.length, + stepLabel: t( + "portal.accountLink.connect.step", + "Step {{current}} of {{total}}", + { current, total: STEP_ORDER.length }, ), - ); - } catch { - setError( - t( - "portal.accountLink.modal.startFailed", - "Could not reach Stirling to start the connection. Check this server's outbound network access, then try again.", - ), - ); - } finally { - setBusy(false); - } - }, [reauth, t]); + }; return ( - -
-
    -
  1. - {t( - "portal.accountLink.modal.step1", - "We send you to stirling.com to sign in. Any sign-in method works there, including Google and single sign-on.", - )} -
  2. -
  3. - {t( - "portal.accountLink.modal.step2", - "You check this server's address and approve it. A team owner has to do this the first time.", - )} -
  4. -
  5. - {t( - "portal.accountLink.modal.step3", - "Stirling brings you straight back here and finishes up.", - )} -
  6. -
- - {!isSaasSupabaseConfigured && ( - - {t("portal.accountLink.modal.loginNotConfigured.before", "Set")}{" "} - VITE_SUPABASE_URL{" "} - {t("portal.accountLink.modal.loginNotConfigured.and", "and")}{" "} - VITE_SUPABASE_PUBLISHABLE_DEFAULT_KEY{" "} - {t( - "portal.accountLink.modal.loginNotConfigured.after", - "so this server can finish the connection when you come back.", - )} - - )} - - {error && {error}} - -
- - -
-
-
+ + {stepBody()} + ); + + function stepTitle(): string { + if (reauth) { + return t("portal.accountLink.modal.reauthTitle", "Sign in again"); + } + if (step === "ask") { + return t( + "portal.accountLink.modal.linkTitle", + "Connect your Stirling account", + ); + } + if (step === "handoff") { + return t("portal.accountLink.connect.handoff.title", "Connecting"); + } + if (outcome?.state === "linked") { + return t("portal.accountLink.connect.done.title", "Connected"); + } + return t("portal.accountLink.connect.done.pendingTitle", "Almost there"); + } + + function stepBody() { + switch (step) { + case "ask": + return ; + case "handoff": + return ; + case "outcome": + return outcome ? ( + + ) : null; + } + } + + function closeButton() { + return ( + + ); + } + + function retryButton(onRetry: () => void) { + return ( + + ); + } + + function stepFooter() { + if (step === "ask") { + const dismiss = reauth + ? t("portal.accountLink.modal.cancel", "Cancel") + : t("portal.accountLink.connect.notNow", "Not now"); + const start = reauth + ? t("portal.accountLink.modal.continueReauth", "Sign in again") + : t("portal.accountLink.connect.start", "Connect Stirling account"); + return ( + <> + + + + ); + } + + // The request is out and the browser is leaving; Close so a stall is not a dead end. + if (step === "handoff") { + return ( + <> + + {closeButton()} + + ); + } + + // A retry over a call that has not answered is how you get two handshakes. + if (outcome?.state === "working") { + return ( + <> + + {closeButton()} + + ); + } + + // Still open: re-claim rather than spend the approval a leader gave by hand. + if (outcome?.reclaim) { + return ( + <> + {closeButton()} + {retryButton(outcome.reclaim)} + + ); + } + + if (outcome && isRetryableOutcome(outcome.state)) { + return ( + <> + {closeButton()} + {retryButton(handoff.begin)} + + ); + } + + return ( + <> + + + + ); + } } diff --git a/frontend/editor/src/portal/components/account-link/LinkGate.tsx b/frontend/editor/src/portal/components/account-link/LinkGate.tsx deleted file mode 100644 index d5743d0041..0000000000 --- a/frontend/editor/src/portal/components/account-link/LinkGate.tsx +++ /dev/null @@ -1,52 +0,0 @@ -import type { ReactNode } from "react"; -import { useTranslation } from "react-i18next"; -import { Banner, Button } from "@app/ui"; -import { useLink } from "@portal/contexts/LinkContext"; -import { useUI } from "@portal/contexts/UIContext"; - -interface Props { - /** The billable feature — rendered only when the org is linked. */ - children: ReactNode; - /** Feature name for the lock copy, e.g. "AI extraction". */ - feature?: string; -} - -/** - * Gates billable features on the account-link state. When the org is unlinked it - * renders a "link to unlock" prompt instead of the feature; once linked (free or - * subscribed) the children render. Drop this around any surface that should only - * work against a linked SaaS wallet. - */ -export function LinkGate({ children, feature }: Props) { - const { t } = useTranslation(); - const { featuresUnlocked } = useLink(); - const { openLinkModal } = useUI(); - - if (featuresUnlocked) return <>{children}; - - return ( - openLinkModal()}> - {t("portal.accountLink.gate.action", "Link account")} - - } - /> - ); -} diff --git a/frontend/editor/src/portal/components/account-link/connect/ConnectAskStep.tsx b/frontend/editor/src/portal/components/account-link/connect/ConnectAskStep.tsx new file mode 100644 index 0000000000..7c795645b7 --- /dev/null +++ b/frontend/editor/src/portal/components/account-link/connect/ConnectAskStep.tsx @@ -0,0 +1,52 @@ +import { useTranslation } from "react-i18next"; +import { Banner } from "@app/ui"; +import { isSaasSupabaseConfigured } from "@portal/auth/saasSupabase"; +import { ConnectBenefitsSlide } from "@portal/components/account-link/connect/ConnectBenefitsSlide"; +import "@portal/components/account-link/connect/connect.css"; + +interface Props { + /** Re-auth says why it is being asked; a first link is pitched instead. */ + reauth: boolean; + /** A hand-off that failed to start drops back here, so this is where its reason belongs. */ + error?: string | null; +} + +export function ConnectAskStep({ reauth, error }: Props) { + const { t } = useTranslation(); + + return ( + <> + {reauth ? ( +

+ {t( + "portal.accountLink.connect.handoff.reauthLede", + "Your Stirling session expired. Signing in again keeps usage and billing visible. This server stays connected either way.", + )} +

+ ) : ( + + )} + + {!isSaasSupabaseConfigured && ( + + {t("portal.accountLink.modal.loginNotConfigured.before", "Set")}{" "} + VITE_SUPABASE_URL{" "} + {t("portal.accountLink.modal.loginNotConfigured.and", "and")}{" "} + VITE_SUPABASE_PUBLISHABLE_DEFAULT_KEY{" "} + {t( + "portal.accountLink.modal.loginNotConfigured.after", + "so this server can finish the connection when you come back.", + )} + + )} + + {error && {error}} + + ); +} diff --git a/frontend/editor/src/portal/components/account-link/connect/ConnectBenefitsSlide.stories.tsx b/frontend/editor/src/portal/components/account-link/connect/ConnectBenefitsSlide.stories.tsx new file mode 100644 index 0000000000..bdad6f32b0 --- /dev/null +++ b/frontend/editor/src/portal/components/account-link/connect/ConnectBenefitsSlide.stories.tsx @@ -0,0 +1,12 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { ConnectBenefitsSlide } from "@portal/components/account-link/connect/ConnectBenefitsSlide"; + +const meta: Meta = { + title: "Portal/AccountLink/Connect/BenefitsSlide", + component: ConnectBenefitsSlide, +}; +export default meta; +type Story = StoryObj; + +/** Step 1 of the Connect flow: the case for linking. */ +export const Default: Story = {}; diff --git a/frontend/editor/src/portal/components/account-link/connect/ConnectBenefitsSlide.tsx b/frontend/editor/src/portal/components/account-link/connect/ConnectBenefitsSlide.tsx new file mode 100644 index 0000000000..a0258f7802 --- /dev/null +++ b/frontend/editor/src/portal/components/account-link/connect/ConnectBenefitsSlide.tsx @@ -0,0 +1,55 @@ +import { useTranslation } from "react-i18next"; +import "@portal/components/account-link/connect/connect.css"; + +/** + * Processor is one row naming its parts rather than four competing ones, and credits come last: + * first, and the screen reads as a price list. + * + *

TODO(#7712): the credits row promises a monthly allowance the billing model does not grant — + * {@code freeGrantUnits} is a one-time lifetime pool — so either the grant becomes recurring or the + * copy drops "per month" before this reaches customers. + */ +export function ConnectBenefitsSlide() { + const { t } = useTranslation(); + + const unlocks: { key: string; label: string; detail: string }[] = [ + { + key: "processor", + label: t( + "portal.accountLink.connect.benefits.processorLabel", + "Processor", + ), + detail: t( + "portal.accountLink.connect.benefits.processorDetail", + "Pipelines, policies, sources and audit", + ), + }, + { + key: "teams", + label: t("portal.accountLink.connect.benefits.teamsLabel", "Teams"), + detail: t( + "portal.accountLink.connect.benefits.teamsDetail", + "Free for up to 5 users", + ), + }, + { + key: "credits", + label: t("portal.accountLink.connect.benefits.creditsLabel", "Credits"), + detail: t( + "portal.accountLink.connect.benefits.creditsDetail", + "500 free per month", + ), + }, + ]; + + return ( +

+ {unlocks.map((unlock) => ( +
+
{unlock.label}
+
{unlock.detail}
+
+ ))} +
+ ); +} diff --git a/frontend/editor/src/portal/components/account-link/connect/ConnectDoneSlide.test.tsx b/frontend/editor/src/portal/components/account-link/connect/ConnectDoneSlide.test.tsx new file mode 100644 index 0000000000..fa57d33fbf --- /dev/null +++ b/frontend/editor/src/portal/components/account-link/connect/ConnectDoneSlide.test.tsx @@ -0,0 +1,84 @@ +import { describe, expect, it, vi } from "vitest"; +import { render, screen, waitFor } from "@testing-library/react"; +import { MemoryRouter } from "react-router-dom"; +import { PortalTestProviders } from "@portal/test/TestQueryProvider"; + +/** + * The figure is checkable against a real account, so it must be the wallet's and never a hardcoded + * 500. The upgrade row appears only on a nearly spent trial: a prompt to pay is the wrong note on a + * screen confirming a free connection. + */ +const { fetchWallet } = vi.hoisted(() => ({ fetchWallet: vi.fn() })); +vi.mock("@portal/api/billing", () => ({ fetchWallet })); + +import { ConnectDoneSlide } from "@portal/components/account-link/connect/ConnectDoneSlide"; +import { freeWallet } from "@portal/components/billing/walletFixtures"; + +/** The shared fixture, wound down to the balance under test. */ +const wallet = (freeRemaining: number) => ({ + ...freeWallet, + freeRemaining, + billableUsed: freeWallet.freeAllowance - freeRemaining, +}); + +const SWITCH_ON = /Switch on the Processor/; + +const renderDone = () => + render( + + + {}} /> + + , + ); + +describe("ConnectDoneSlide", () => { + it("shows the wallet's remaining balance, not a fixed grant", async () => { + fetchWallet.mockResolvedValue(wallet(128)); + renderDone(); + await waitFor(() => expect(screen.getByText("128")).toBeTruthy()); + }); + + it("shows a spent grant as zero rather than hiding it", async () => { + fetchWallet.mockResolvedValue(wallet(0)); + renderDone(); + await waitFor(() => expect(screen.getByText("0")).toBeTruthy()); + }); + + it("omits the meter entirely when the wallet cannot be read", async () => { + fetchWallet.mockRejectedValue(new Error("not linked")); + renderDone(); + await waitFor(() => + expect(screen.getByText("Invite your team")).toBeTruthy(), + ); + expect(document.querySelector(".paygf-meter")).toBeNull(); + expect(screen.queryByText(SWITCH_ON)).toBeNull(); + }); + + it("asks the admin to switch the Processor on once the trial is nearly gone", async () => { + fetchWallet.mockResolvedValue(wallet(40)); + renderDone(); + await waitFor(() => expect(screen.getByText(SWITCH_ON)).toBeTruthy()); + const rows = [...document.querySelectorAll(".portal-connect__next-item")]; + expect(rows[0]?.textContent).toMatch(SWITCH_ON); + }); + + it("leaves the upgrade unmentioned while there is trial left to use", async () => { + fetchWallet.mockResolvedValue(wallet(500)); + renderDone(); + await waitFor(() => + expect(document.querySelector(".paygf-meter")).toBeTruthy(), + ); + expect(screen.queryByText(SWITCH_ON)).toBeNull(); + }); + + it("always offers the next steps", async () => { + fetchWallet.mockResolvedValue(wallet(500)); + renderDone(); + await waitFor(() => + expect(screen.getByText("Invite your team")).toBeTruthy(), + ); + expect(screen.getByText("Set up a pipeline")).toBeTruthy(); + expect(screen.getByText("Add a policy")).toBeTruthy(); + }); +}); diff --git a/frontend/editor/src/portal/components/account-link/connect/ConnectDoneSlide.tsx b/frontend/editor/src/portal/components/account-link/connect/ConnectDoneSlide.tsx new file mode 100644 index 0000000000..4854048ab8 --- /dev/null +++ b/frontend/editor/src/portal/components/account-link/connect/ConnectDoneSlide.tsx @@ -0,0 +1,157 @@ +import { useEffect, useState } from "react"; +import { useTranslation } from "react-i18next"; +import { useNavigate } from "react-router-dom"; +import { MeterBar, remainingMeter } from "@app/billing"; +import { fetchWallet, type Wallet } from "@portal/api/billing"; +import { useLinkedAccountEmail } from "@portal/hooks/useLinkedAccountEmail"; +import { VIEW_PATHS, toPortalPath } from "@portal/contexts/ViewContext"; +import "@portal/components/billing/billing.css"; +import "@portal/components/account-link/connect/connect.css"; + +interface Props { + /** Closes the dialog first, so a next step does not land behind the overlay. */ + onNavigate: () => void; +} + +/** Below this the meter grows an upgrade row; above it the meter is information, not a prompt. */ +const LOW_CREDITS = 100; + +function Chevron() { + return ( + + + + ); +} + +/** + * The bar without the plan card around it: at dialog width that card's headline and price wrapped + * over four lines. Upgrading is a row rather than a button on the bar for the same reason, and it + * goes to Usage rather than starting checkout, which already lives there with its quotes and + * resumable bundle. + */ +export function ConnectDoneSlide({ onNavigate }: Props) { + const { t } = useTranslation(); + const navigate = useNavigate(); + const email = useLinkedAccountEmail(); + const [wallet, setWallet] = useState(null); + + useEffect(() => { + let cancelled = false; + void fetchWallet() + .then((w) => { + if (!cancelled) setWallet(w); + }) + .catch(() => { + // The wallet needs a live SaaS session and a team that has finished provisioning. Neither + // is guaranteed the instant a link completes, and neither is worth blocking this screen on. + }); + return () => { + cancelled = true; + }; + }, []); + + const go = (path: string) => { + onNavigate(); + navigate(path); + }; + + const lowOnCredits = wallet != null && wallet.freeRemaining < LOW_CREDITS; + + const nextSteps: { key: string; label: string; path: string }[] = [ + ...(lowOnCredits + ? [ + { + key: "processor", + label: t( + "portal.accountLink.connect.done.switchOnProcessor", + "Switch on the Processor", + ), + path: toPortalPath(VIEW_PATHS.usage), + }, + ] + : []), + { + key: "team", + label: t( + "portal.accountLink.connect.done.inviteTeam", + "Invite your team", + ), + path: toPortalPath(VIEW_PATHS.users), + }, + { + key: "pipeline", + label: t( + "portal.accountLink.connect.done.buildPipeline", + "Set up a pipeline", + ), + path: `${toPortalPath(VIEW_PATHS.pipelines)}/new`, + }, + { + key: "policy", + label: t("portal.accountLink.connect.done.addPolicy", "Add a policy"), + path: toPortalPath(VIEW_PATHS.policies), + }, + ]; + + return ( + <> + {wallet && ( +
+ {/* No status chip: its tone goes red on an exhausted trial, which on a success screen + reads as something having gone wrong. */} + +
+ )} + + {/* Proves it landed on the account they meant. Read from the session the callback deposited, + so it is absent exactly when that hand-off failed — which the note above already says. */} + {email && ( +
+ + {t("portal.accountLink.connect.done.accountLabel", "Account")} + + {email} +
+ )} + +
    + {nextSteps.map((step) => ( +
  • + +
  • + ))} +
+ + ); +} diff --git a/frontend/editor/src/portal/components/account-link/connect/ConnectHandoffGhost.tsx b/frontend/editor/src/portal/components/account-link/connect/ConnectHandoffGhost.tsx new file mode 100644 index 0000000000..251652de73 --- /dev/null +++ b/frontend/editor/src/portal/components/account-link/connect/ConnectHandoffGhost.tsx @@ -0,0 +1,28 @@ +import { useTranslation } from "react-i18next"; +import { Skeleton } from "@app/ui"; +import "@portal/components/account-link/connect/connect.css"; + +/** + * A ghost rather than a screen: the admin has already decided. It earns its place when the local + * backend is slow to open the handshake, where a blank dialog would look broken. + */ +export function ConnectHandoffGhost() { + const { t } = useTranslation(); + + return ( +
+

+ {t( + "portal.accountLink.connect.handoff.going", + "Taking you to stirling.com", + )} +

+ +
+ + + +
+
+ ); +} diff --git a/frontend/editor/src/portal/components/account-link/connect/connect.css b/frontend/editor/src/portal/components/account-link/connect/connect.css new file mode 100644 index 0000000000..393695bb0a --- /dev/null +++ b/frontend/editor/src/portal/components/account-link/connect/connect.css @@ -0,0 +1,112 @@ +/* Connect flow, the three account-link steps. + + Deliberately flat: bordered rows and plain type, not icon tiles or coloured + cards. FlowModal stacks and spaces the body, so these blocks own only their + own internals. */ + +.portal-connect__lede { + margin: 0; + font-size: 0.875rem; + line-height: 1.55; + color: var(--c-text-muted); +} + +.portal-connect__list { + margin: 0; + border-top: 1px solid var(--c-border); +} + +.portal-connect__row { + display: flex; + align-items: baseline; + justify-content: space-between; + gap: 1rem; + padding: 0.5rem 0; + border-bottom: 1px solid var(--c-border); +} + +.portal-connect__row-label { + font-size: 0.8125rem; + font-weight: 600; + color: var(--c-text); +} + +.portal-connect__row-detail { + margin: 0; + font-size: 0.8125rem; + color: var(--c-text-muted); + text-align: right; +} + +/* ── Step 2: the ghost ──────────────────────────────────────────────────── */ + +.portal-connect__ghost { + display: flex; + flex-direction: column; + gap: 0.75rem; +} + +.portal-connect__ghost-bars { + display: flex; + flex-direction: column; + gap: 0.5rem; +} + +/* ── Step 3 ─────────────────────────────────────────────────────────────── */ + +/* Standalone, so it needs the top edge the benefits list would have given it. */ +.portal-connect__row--standalone { + border-top: 1px solid var(--c-border); +} + +/* The shared bar without the plan card it wears on Usage; padded into the row rhythm. */ +.portal-connect__meter { + padding: 0.25rem 0 0.5rem; +} + +/* Rows, not full-width buttons: three competing primaries were the loudest thing + on a screen whose job is to confirm. Same flat row as step 1, so both ends match. */ +.portal-connect__next { + display: flex; + flex-direction: column; + margin: 0; + padding: 0; + list-style: none; + border-top: 1px solid var(--c-border); +} + +/* The Account row above draws a bottom edge; both would render as a double line. */ +.portal-connect__row + .portal-connect__next { + border-top: 0; +} + +.portal-connect__next-item { + display: flex; + align-items: center; + justify-content: space-between; + gap: 1rem; + width: 100%; + padding: 0.625rem 0.25rem; + border: 0; + border-bottom: 1px solid var(--c-border); + background: none; + font: inherit; + font-size: 0.875rem; + font-weight: 500; + color: var(--c-text); + text-align: left; + cursor: pointer; +} + +.portal-connect__next-item:hover { + color: var(--c-accent-text); +} + +.portal-connect__next-chevron { + flex: none; + color: var(--c-text-subtle); +} + +.portal-connect__next-item:hover .portal-connect__next-chevron { + color: var(--c-accent-text); +} diff --git a/frontend/editor/src/portal/components/billing/LinkAccountPrompt.stories.tsx b/frontend/editor/src/portal/components/billing/LinkAccountPrompt.stories.tsx deleted file mode 100644 index 20a9ab2e80..0000000000 --- a/frontend/editor/src/portal/components/billing/LinkAccountPrompt.stories.tsx +++ /dev/null @@ -1,14 +0,0 @@ -import type { Meta, StoryObj } from "@storybook/react-vite"; -import { LinkAccountPrompt } from "@portal/components/billing/LinkAccountPrompt"; -import "@portal/components/billing/billing.css"; - -const meta: Meta = { - title: "Portal/Billing/LinkAccountPrompt", - component: LinkAccountPrompt, - parameters: { layout: "padded" }, -}; -export default meta; -type Story = StoryObj; - -/** Unlinked billing page — CTA opens the login modal (UIProvider from the preview decorator). */ -export const Default: Story = {}; diff --git a/frontend/editor/src/portal/components/billing/LinkAccountPrompt.tsx b/frontend/editor/src/portal/components/billing/LinkAccountPrompt.tsx deleted file mode 100644 index 394668c56b..0000000000 --- a/frontend/editor/src/portal/components/billing/LinkAccountPrompt.tsx +++ /dev/null @@ -1,33 +0,0 @@ -import { useTranslation } from "react-i18next"; -import { Button, Card, EmptyState } from "@app/ui"; -import { useUI } from "@portal/contexts/UIContext"; - -/** - * Unlinked state — the billing page asks the admin to link their Stirling - * account to claim the 500-PDF free grant. The CTA opens the login modal - * directly (no detour through Settings). - */ -export function LinkAccountPrompt() { - const { t } = useTranslation(); - const { openLinkModal } = useUI(); - return ( - - openLinkModal()}> - {t("portal.billing.linkPrompt.cta", "Link Stirling account")} - - } - /> - - ); -} diff --git a/frontend/editor/src/portal/components/billing/PortalBillingGate.test.tsx b/frontend/editor/src/portal/components/billing/PortalBillingGate.test.tsx index f15d8ade51..097eebd52e 100644 --- a/frontend/editor/src/portal/components/billing/PortalBillingGate.test.tsx +++ b/frontend/editor/src/portal/components/billing/PortalBillingGate.test.tsx @@ -1,39 +1,82 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; import { render, screen } from "@testing-library/react"; +import { MemoryRouter, Route, Routes } from "react-router-dom"; -const linkState = { isLinked: false }; +/** + * Usage must not render while unlinked: it reports `linked` as a fact from its wallet read, so a + * browser holding a SaaS session with no link to this server would flip the whole portal to linked. + */ +const gate = { gated: false, loading: false, available: true }; +const connect = vi.fn(); +const applyLinkFacts = vi.fn(); + +vi.mock("@portal/hooks/useConnectGate", () => ({ + useConnectGate: () => ({ ...gate, connect, guard: (f: unknown) => f }), +})); vi.mock("@portal/contexts/LinkContext", () => ({ - useLink: () => linkState, - useApplyLinkFacts: () => vi.fn(), + useApplyLinkFacts: () => applyLinkFacts, })); vi.mock("@portal/contexts/UIContext", () => ({ useUI: () => ({ openLinkModal: vi.fn() }), })); -vi.mock("@portal/components/billing/LinkAccountPrompt", () => ({ - LinkAccountPrompt: () =>
, -})); vi.mock("@portal/views/Usage", () => ({ - Usage: () =>
, + Usage: ({ onWalletLoaded }: { onWalletLoaded?: (w: unknown) => void }) => { + onWalletLoaded?.({ status: "free" }); + return
; + }, })); import { PortalBillingGate } from "@portal/components/billing/PortalBillingGate"; +const renderGate = () => + render( + + + } /> + } /> + + , + ); + describe("PortalBillingGate — self-hosted", () => { beforeEach(() => { - linkState.isLinked = false; + connect.mockReset(); + applyLinkFacts.mockReset(); + gate.gated = false; + gate.loading = false; }); - it("shows the link prompt when unlinked (billing gated on link)", () => { - linkState.isLinked = false; - render(); - expect(screen.getByTestId("link-prompt")).toBeInTheDocument(); - expect(screen.queryByTestId("usage")).not.toBeInTheDocument(); + it("asks for the connection when reached unconnected", () => { + gate.gated = true; + renderGate(); + expect(connect).toHaveBeenCalledTimes(1); }); - it("renders the Usage page once linked", () => { - linkState.isLinked = true; - render(); + it("sends them back rather than onto a page about an account they lack", () => { + gate.gated = true; + renderGate(); + expect(screen.queryByTestId("usage")).toBeNull(); + expect(screen.getByTestId("home")).toBeInTheDocument(); + }); + + it("never reports the instance as linked while it is not", () => { + gate.gated = true; + renderGate(); + // Not rendering the page is what stops the claim. + expect(applyLinkFacts).not.toHaveBeenCalled(); + }); + + it("holds while the capability is still unknown, rather than bouncing", () => { + gate.loading = true; + renderGate(); + expect(screen.queryByTestId("usage")).toBeNull(); + expect(screen.queryByTestId("home")).toBeNull(); + }); + + it("renders the page once connected", () => { + renderGate(); + expect(connect).not.toHaveBeenCalled(); expect(screen.getByTestId("usage")).toBeInTheDocument(); - expect(screen.queryByTestId("link-prompt")).not.toBeInTheDocument(); + expect(applyLinkFacts).toHaveBeenCalledWith(true, false); }); }); diff --git a/frontend/editor/src/portal/components/billing/PortalBillingGate.tsx b/frontend/editor/src/portal/components/billing/PortalBillingGate.tsx index e448cfb93a..b02ed7db29 100644 --- a/frontend/editor/src/portal/components/billing/PortalBillingGate.tsx +++ b/frontend/editor/src/portal/components/billing/PortalBillingGate.tsx @@ -1,23 +1,20 @@ import { useCallback } from "react"; -import { useApplyLinkFacts, useLink } from "@portal/contexts/LinkContext"; +import { useApplyLinkFacts } from "@portal/contexts/LinkContext"; import { useUI } from "@portal/contexts/UIContext"; -import { LinkAccountPrompt } from "@portal/components/billing/LinkAccountPrompt"; +import { ConnectGuardedRoute } from "@portal/components/account-link/ConnectGuardedRoute"; +import { VIEW_PATHS, toPortalPath } from "@portal/contexts/ViewContext"; import { Usage } from "@portal/views/Usage"; import type { Wallet } from "@portal/api/billing"; /** - * Billing access gate — the seam the SaaS build overrides. + * The seam the SaaS build shadows, and the backstop for a typed URL — the nav already refuses to + * come here unlinked (the sidebar's requiresLink). * - *

Self-hosted (this base): billing only makes sense once the instance has - * linked its SaaS account, so gate on link state — unlinked shows the link prompt; - * linked renders the (flavor-agnostic) Usage page and maps its callbacks onto the - * link/tier dimension: the wallet's subscription status refines the plan/tier - * badge, and a lapsed SaaS session re-opens the account-link re-auth. This keeps - * the "link" concept entirely out of the Usage page. The SaaS build shadows this - * with a passthrough — there is no linking there. + *

Usage must not render while unlinked: {@link onWalletLoaded} reports linked as a fact, and the + * browser can hold a SaaS session with no link to this server, so rendering it flipped the portal + * to linked. */ export function PortalBillingGate() { - const { isLinked } = useLink(); const applyLinkFacts = useApplyLinkFacts(); const { openLinkModal } = useUI(); @@ -27,6 +24,9 @@ export function PortalBillingGate() { ); const onReauth = useCallback(() => openLinkModal("reauth"), [openLinkModal]); - if (!isLinked) return ; - return ; + return ( + + + + ); } diff --git a/frontend/editor/src/portal/components/shared/StepModalHeader.css b/frontend/editor/src/portal/components/shared/StepModalHeader.css index 7296e9752f..fd918f1c62 100644 --- a/frontend/editor/src/portal/components/shared/StepModalHeader.css +++ b/frontend/editor/src/portal/components/shared/StepModalHeader.css @@ -26,7 +26,7 @@ color: var(--c-text); } -/* Trademarked wordmark SVG (theme-switched via .wordmark-light-only/.wordmark-dark-only). Height +/* Trademarked wordmark SVG (theme-switched via .wordmark/.wordmark-dark-only). Height matches the portal nav's 22px wordmark so the modal and app read as one brand. No `display` here — the theme-switch utilities own visibility. */ .portal-stepmodal__wordmark { diff --git a/frontend/editor/src/portal/components/shared/StepModalHeader.tsx b/frontend/editor/src/portal/components/shared/StepModalHeader.tsx index 3156d17a9e..9190893745 100644 --- a/frontend/editor/src/portal/components/shared/StepModalHeader.tsx +++ b/frontend/editor/src/portal/components/shared/StepModalHeader.tsx @@ -55,10 +55,12 @@ export function StepModalHeader({

{brand ? (
+ {/* `wordmark`, not `wordmark-light-only`: theme.css hides the former in dark mode and + has no rule for the latter, so both used to render at once. */} Stirling }, { id: "integrations", icon: }, { id: "infrastructure", icon: }, - { id: "usage", icon: }, + { id: "usage", icon: , requiresLink: true }, { id: "docs", icon: }, ]; diff --git a/frontend/editor/src/portal/contexts/LinkContext.tsx b/frontend/editor/src/portal/contexts/LinkContext.tsx index 7fd7cfe536..8e36b80cee 100644 --- a/frontend/editor/src/portal/contexts/LinkContext.tsx +++ b/frontend/editor/src/portal/contexts/LinkContext.tsx @@ -89,6 +89,14 @@ export function useLink(): LinkContextValue { return v; } +/** + * Null rather than throwing where there is no provider. The SaaS portal mounts none on purpose, so + * absent means "linking does not apply here" — a real answer, not a mistake. + */ +export function useLinkOptional(): LinkContextValue | null { + return useContext(LinkContext); +} + /** * Derives the linked state from raw facts: whether the org has linked its SaaS * account and whether it carries a live subscription. Keeps the unlinked / diff --git a/frontend/editor/src/portal/contexts/UIContext.tsx b/frontend/editor/src/portal/contexts/UIContext.tsx index b5ebada257..b116486271 100644 --- a/frontend/editor/src/portal/contexts/UIContext.tsx +++ b/frontend/editor/src/portal/contexts/UIContext.tsx @@ -5,6 +5,7 @@ import { useState, type ReactNode, } from "react"; +import type { ConnectOutcome } from "@portal/components/account-link/ConnectCallbackView"; interface UIContextValue { /** Off-canvas sidebar drawer on small screens (no-op chrome on desktop). */ @@ -46,6 +47,13 @@ interface UIContextValue { linkModalMode: "link" | "reauth"; openLinkModal: (mode?: "link" | "reauth") => void; closeLinkModal: () => void; + /** + * A one-shot signal like {@link UIContextValue.trialSetupRequested}: the callback route and the + * dialog mount separately, and there must only ever be one link dialog. + */ + connectOutcome: ConnectOutcome | null; + publishConnectOutcome: (outcome: ConnectOutcome) => void; + clearConnectOutcome: () => void; /** * A request to begin the enterprise trial, raised from wherever the buyer said yes (the billing * upsell, a sales link). The deal controller lives on Home, so this is a one-shot signal rather @@ -91,6 +99,9 @@ export function UIProvider({ children }: { children: ReactNode }) { const [linkModalOpen, setLinkModalOpen] = useState(false); const [trialSetupRequested, setTrialSetupRequested] = useState(false); const [linkModalMode, setLinkModalMode] = useState<"link" | "reauth">("link"); + const [connectOutcome, setConnectOutcome] = useState( + null, + ); // When the link modal is opened from inside Settings, remember the section to // restore so closing the modal returns the admin to where they were. const [reopenSettingsAfterLink, setReopenSettingsAfterLink] = useState< @@ -155,9 +166,19 @@ export function UIProvider({ children }: { children: ReactNode }) { setTrialSetupRequested(true); }, clearTrialSetupRequest: () => setTrialSetupRequested(false), + connectOutcome, + publishConnectOutcome: (outcome: ConnectOutcome) => { + setMobileNavOpen(false); + setConnectOutcome(outcome); + setLinkModalMode("link"); + setLinkModalOpen(true); + }, + clearConnectOutcome: () => setConnectOutcome(null), closeLinkModal: () => { setLinkModalOpen(false); setLinkModalMode("link"); + // A reopen from a CTA is a fresh flow, not a handshake already dismissed. + setConnectOutcome(null); if (reopenSettingsAfterLink) { setSettingsInitialSection(reopenSettingsAfterLink); setSettingsInitialFocus(null); @@ -177,6 +198,7 @@ export function UIProvider({ children }: { children: ReactNode }) { linkModalMode, reopenSettingsAfterLink, trialSetupRequested, + connectOutcome, ], ); diff --git a/frontend/editor/src/portal/hooks/useConnectGate.test.tsx b/frontend/editor/src/portal/hooks/useConnectGate.test.tsx new file mode 100644 index 0000000000..00cd34d643 --- /dev/null +++ b/frontend/editor/src/portal/hooks/useConnectGate.test.tsx @@ -0,0 +1,78 @@ +import { describe, expect, it, vi, beforeEach } from "vitest"; +import { render, screen, waitFor } from "@testing-library/react"; +import { PortalTestProviders } from "@portal/test/TestQueryProvider"; +import { LinkProvider, type LinkState } from "@portal/contexts/LinkContext"; +import { UIProvider } from "@portal/contexts/UIContext"; + +/** + * The gate answers two questions, and conflating them is the failure that matters: an instance + * running with the account-link flag off CANNOT link, so gating on link state alone would lock + * Pipelines, Policies, Users, Sources and Integrations on every default install with no way out. + */ +const { json } = vi.hoisted(() => ({ json: vi.fn() })); +vi.mock("@portal/api/http", () => ({ + apiClient: { local: { json } }, + errorMessage: (e: unknown) => String(e), +})); + +import { useConnectGate } from "@portal/hooks/useConnectGate"; + +function Probe() { + const { gated, loading, available } = useConnectGate(); + return ( + + {loading + ? "loading" + : `${available ? "available" : "unavailable"}:${gated ? "gated" : "open"}`} + + ); +} + +function renderProbe(linkState: LinkState) { + return render( + + + + + + + , + ); +} + +const settled = async (expected: string) => + waitFor(() => expect(screen.getByTestId("state").textContent).toBe(expected)); + +describe("useConnectGate", () => { + beforeEach(() => json.mockReset()); + + it("gates an unlinked instance that can link", async () => { + json.mockResolvedValue({ accountLinkAvailable: true }); + renderProbe("unlinked"); + await settled("available:gated"); + }); + + it("does not gate when linking is unavailable, whatever the link state", async () => { + json.mockResolvedValue({ accountLinkAvailable: false }); + renderProbe("unlinked"); + await settled("unavailable:open"); + }); + + it("does not gate a linked instance", async () => { + json.mockResolvedValue({ accountLinkAvailable: true }); + renderProbe("linked-free"); + await settled("available:open"); + }); + + it("treats a missing flag as unavailable rather than gating on a guess", async () => { + json.mockResolvedValue({}); + renderProbe("unlinked"); + await settled("unavailable:open"); + }); + + it("does not gate while the capability is still unknown", async () => { + json.mockResolvedValue({ accountLinkAvailable: true }); + renderProbe("unlinked"); + expect(screen.getByTestId("state").textContent).toBe("loading"); + }); +}); diff --git a/frontend/editor/src/portal/hooks/useConnectGate.ts b/frontend/editor/src/portal/hooks/useConnectGate.ts new file mode 100644 index 0000000000..253ee26212 --- /dev/null +++ b/frontend/editor/src/portal/hooks/useConnectGate.ts @@ -0,0 +1,63 @@ +import { useCallback } from "react"; +import { useQuery } from "@tanstack/react-query"; +import { apiClient } from "@portal/api/http"; +import { qk } from "@portal/queries/keys"; +import { useLinkOptional } from "@portal/contexts/LinkContext"; +import { useUI } from "@portal/contexts/UIContext"; +import { useDevConnectBypass } from "@portal/hooks/useDevConnectBypass"; + +interface AppConfigShape { + accountLinkAvailable?: boolean; +} + +interface ConnectGate { + /** Can link but has not, so gated features must ask first. */ + gated: boolean; + /** Capability still unknown; hold the decision rather than flash a gate. */ + loading: boolean; + /** Whether linking is possible here at all, i.e. the feature flag is on. */ + available: boolean; + connect: () => void; + /** Wraps a create or edit handler so the click asks for a connection instead. */ + guard: ( + action: (...args: A) => void, + ) => (...args: A) => void; +} + +/** + * Two facts, not one: linked, and *could* be linked. The account-link endpoints 404 with the flag + * off, which the client cannot tell from "not linked yet", so gating on link state alone would lock + * these features on every default install. + */ +export function useConnectGate(): ConnectGate { + // Optional: the SaaS portal mounts no LinkProvider, and no provider means nothing to gate. + const link = useLinkOptional(); + const { openLinkModal } = useUI(); + const devBypass = useDevConnectBypass(); + + const query = useQuery({ + queryKey: qk.appConfig(), + queryFn: () => + apiClient.local.json("/api/v1/config/app-config"), + }); + + const available = Boolean(query.data?.accountLinkAvailable) && link != null; + const loading = query.isPending; + const gated = available && !link?.isLinked && !devBypass; + + const connect = useCallback(() => openLinkModal(), [openLinkModal]); + + const guard = useCallback( + (action: (...args: A) => void) => + (...args: A) => { + if (gated) { + openLinkModal(); + return; + } + action(...args); + }, + [gated, openLinkModal], + ); + + return { gated, loading, available, connect, guard }; +} diff --git a/frontend/editor/src/portal/hooks/useConnectHandoff.ts b/frontend/editor/src/portal/hooks/useConnectHandoff.ts new file mode 100644 index 0000000000..1349086085 --- /dev/null +++ b/frontend/editor/src/portal/hooks/useConnectHandoff.ts @@ -0,0 +1,64 @@ +import { useCallback, useEffect, useState } from "react"; +import { useTranslation } from "react-i18next"; +import { withBasePath } from "@app/constants/app"; +import { startConnect, startReauth } from "@portal/api/link"; + +interface ConnectHandoff { + /** Stays true through a successful hand-off: the page is leaving, so nothing resolves. */ + busy: boolean; + error: string | null; + begin: () => void; +} + +export function useConnectHandoff(reauth: boolean): ConnectHandoff { + const { t } = useTranslation(); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(null); + + useEffect(() => { + // Back from Stirling can restore this page with its heap intact, leaving busy stuck on and the + // dialog pinned to the ghost step. Being shown at all means we are not mid-navigation. + const shown = () => setBusy(false); + window.addEventListener("pageshow", shown); + return () => window.removeEventListener("pageshow", shown); + }, []); + + const begin = useCallback(() => { + setBusy(true); + setError(null); + void (async () => { + try { + // Stated, not inferred: only the frontend knows its own base path. + const callbackUrl = new URL( + withBasePath("/account-link/callback"), + window.location.origin, + ).toString(); + const status = reauth + ? await startReauth(callbackUrl) + : await startConnect(window.location.hostname, callbackUrl); + if (status.authorizeUrl) { + window.location.assign(status.authorizeUrl); + return; + } + // Already linked, or a handshake we cannot act on. Nothing to navigate to. + setError( + t( + "portal.accountLink.modal.noAuthorizeUrl", + "Stirling did not return somewhere to continue. Try again in a moment.", + ), + ); + setBusy(false); + } catch { + setError( + t( + "portal.accountLink.modal.startFailed", + "Could not reach Stirling to start the connection. Check this server's outbound network access, then try again.", + ), + ); + setBusy(false); + } + })(); + }, [reauth, t]); + + return { busy, error, begin }; +} diff --git a/frontend/editor/src/portal/hooks/useConnectPrompt.test.tsx b/frontend/editor/src/portal/hooks/useConnectPrompt.test.tsx new file mode 100644 index 0000000000..dc54fce35c --- /dev/null +++ b/frontend/editor/src/portal/hooks/useConnectPrompt.test.tsx @@ -0,0 +1,74 @@ +import { describe, expect, it, vi, beforeEach } from "vitest"; +import { render, waitFor } from "@testing-library/react"; + +/** + * The cadence decision: dismissible, but it always comes back. Persisting "seen" would end the ask + * after one dismissal, so the marker has to be session scoped and has to be written when the prompt + * opens rather than when it closes, or an admin who ignores the dialog gets it again on every + * re-render. + */ +const { connect, gate } = vi.hoisted(() => ({ + connect: vi.fn(), + gate: { gated: true, loading: false, available: true }, +})); + +vi.mock("@portal/hooks/useConnectGate", () => ({ + useConnectGate: () => ({ ...gate, connect, guard: (f: unknown) => f }), +})); + +import { useConnectPrompt } from "@portal/hooks/useConnectPrompt"; + +function Probe() { + useConnectPrompt(); + return null; +} + +describe("useConnectPrompt", () => { + beforeEach(() => { + connect.mockReset(); + sessionStorage.clear(); + gate.gated = true; + gate.loading = false; + }); + + it("opens the flow once while unlinked", async () => { + render(); + await waitFor(() => expect(connect).toHaveBeenCalledTimes(1)); + }); + + it("does not open again in the same session", async () => { + const { unmount } = render(); + await waitFor(() => expect(connect).toHaveBeenCalledTimes(1)); + unmount(); + render(); + await waitFor(() => expect(connect).toHaveBeenCalledTimes(1)); + }); + + it("asks again in a fresh session", async () => { + render().unmount(); + await waitFor(() => expect(connect).toHaveBeenCalledTimes(1)); + sessionStorage.clear(); + render(); + await waitFor(() => expect(connect).toHaveBeenCalledTimes(2)); + }); + + it("never persists beyond the session", async () => { + render(); + await waitFor(() => expect(connect).toHaveBeenCalledTimes(1)); + expect(localStorage.length).toBe(0); + }); + + it("stays quiet when the instance is not gated", async () => { + gate.gated = false; + render(); + await new Promise((r) => setTimeout(r, 0)); + expect(connect).not.toHaveBeenCalled(); + }); + + it("waits for the capability rather than prompting on an unknown", async () => { + gate.loading = true; + render(); + await new Promise((r) => setTimeout(r, 0)); + expect(connect).not.toHaveBeenCalled(); + }); +}); diff --git a/frontend/editor/src/portal/hooks/useConnectPrompt.ts b/frontend/editor/src/portal/hooks/useConnectPrompt.ts new file mode 100644 index 0000000000..0a751b5c86 --- /dev/null +++ b/frontend/editor/src/portal/hooks/useConnectPrompt.ts @@ -0,0 +1,37 @@ +import { useEffect, useRef } from "react"; +import { useConnectGate } from "@portal/hooks/useConnectGate"; + +const PROMPTED_KEY = "portal::connect-prompted"; + +function alreadyPrompted(): boolean { + try { + return sessionStorage.getItem(PROMPTED_KEY) === "true"; + } catch { + return false; + } +} + +function markPrompted(): void { + try { + sessionStorage.setItem(PROMPTED_KEY, "true"); + } catch { + // Prompting again later is the harmless direction. + } +} + +/** + * Session storage, not the onboarding localStorage helpers: one dismissal should not end the ask + * for good, and asking once per visit needs no timer to tune. + */ +export function useConnectPrompt(): void { + const { gated, loading, connect } = useConnectGate(); + const fired = useRef(false); + + useEffect(() => { + if (loading || !gated || fired.current || alreadyPrompted()) return; + fired.current = true; + // Marked on open, not on close, so a session gets one whatever the admin does with it. + markPrompted(); + connect(); + }, [gated, loading, connect]); +} diff --git a/frontend/editor/src/portal/hooks/useDevConnectBypass.test.tsx b/frontend/editor/src/portal/hooks/useDevConnectBypass.test.tsx new file mode 100644 index 0000000000..f4d28f5515 --- /dev/null +++ b/frontend/editor/src/portal/hooks/useDevConnectBypass.test.tsx @@ -0,0 +1,53 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { cleanup, render, screen } from "@testing-library/react"; + +/** + * The gate is the only thing making these features need a link, so a switch a customer could reach + * would hand them over. The last test is the one that matters: with DEV folded off, the parameter + * does nothing at all. + */ +import { useDevConnectBypass } from "@portal/hooks/useDevConnectBypass"; + +function Probe() { + return {String(useDevConnectBypass())}; +} + +/** Mounts fresh at the given URL, so a second call models a later visit rather than a re-render. */ +const at = (search: string) => { + cleanup(); + window.history.replaceState({}, "", `/processor${search}`); + render(); + return screen.getByTestId("bypass").textContent; +}; + +describe("useDevConnectBypass", () => { + beforeEach(() => sessionStorage.clear()); + afterEach(() => { + vi.unstubAllEnvs(); + window.history.replaceState({}, "", "/"); + }); + + it("is off by default, so dev still sees what customers see", () => { + expect(at("")).toBe("false"); + }); + + it("turns on with the parameter", () => { + expect(at("?bypassConnect=true")).toBe("true"); + }); + + it("survives the navigation the gate itself performs", () => { + at("?bypassConnect=true"); + expect(at("")).toBe("true"); + }); + + it("ignores any other value", () => { + expect(at("?bypassConnect=1")).toBe("false"); + }); + + it("does nothing in a build, which is what ships to customers", () => { + vi.stubEnv("DEV", false); + expect(at("?bypassConnect=true")).toBe("false"); + // And nothing was left behind for a later dev session to pick up. + expect(sessionStorage.getItem("accountLink::dev-bypass")).toBeNull(); + }); +}); diff --git a/frontend/editor/src/portal/hooks/useDevConnectBypass.ts b/frontend/editor/src/portal/hooks/useDevConnectBypass.ts new file mode 100644 index 0000000000..b201ac7d80 --- /dev/null +++ b/frontend/editor/src/portal/hooks/useDevConnectBypass.ts @@ -0,0 +1,39 @@ +import { useEffect, useState } from "react"; + +const PARAM = "bypassConnect"; +const SESSION_KEY = "accountLink::dev-bypass"; + +function stored(): boolean { + try { + return sessionStorage.getItem(SESSION_KEY) === "true"; + } catch { + return false; + } +} + +/** + * Dev-only escape from the connect gate, fenced behind {@code import.meta.env.DEV} so Vite folds + * the branch away entirely: in a shipped build there is no param and no key that does anything. + * It cannot be a setting — the gate is the only thing making these features need a link, so any + * switch a customer could reach would hand them over. + */ +export function useDevConnectBypass(): boolean { + // Session-scoped so it survives the navigation the gate itself performs. + const [bypassed, setBypassed] = useState( + () => import.meta.env.DEV && stored(), + ); + + useEffect(() => { + if (!import.meta.env.DEV) return; + const params = new URLSearchParams(window.location.search); + if (params.get(PARAM) !== "true") return; + try { + sessionStorage.setItem(SESSION_KEY, "true"); + } catch { + // Still bypassed for this render. + } + setBypassed(true); + }, []); + + return bypassed; +} diff --git a/frontend/editor/src/portal/test/TestQueryProvider.tsx b/frontend/editor/src/portal/test/TestQueryProvider.tsx index d843d816d3..1d3587c858 100644 --- a/frontend/editor/src/portal/test/TestQueryProvider.tsx +++ b/frontend/editor/src/portal/test/TestQueryProvider.tsx @@ -1,6 +1,8 @@ import { useState, type ReactNode } from "react"; import { MantineProvider } from "@mantine/core"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { LinkProvider, type LinkState } from "@portal/contexts/LinkContext"; +import { UIProvider } from "@portal/contexts/UIContext"; /** * Wraps a portal component under test in a fresh QueryClient (retries off for @@ -27,3 +29,20 @@ export function PortalTestProviders({ children }: { children: ReactNode }) { ); } + +/** {@link PortalTestProviders} plus the contexts the connect gate reads. Unlinked by default. */ +export function PortalViewProviders({ + children, + linkState = "unlinked", +}: { + children: ReactNode; + linkState?: LinkState; +}) { + return ( + + + {children} + + + ); +} diff --git a/frontend/editor/src/portal/views/ConnectCallback.css b/frontend/editor/src/portal/views/ConnectCallback.css index 35e9b09ef7..99c0539658 100644 --- a/frontend/editor/src/portal/views/ConnectCallback.css +++ b/frontend/editor/src/portal/views/ConnectCallback.css @@ -1,24 +1,13 @@ -/* Account-link callback. A transient page the admin passes through, so it is - centred and says one thing rather than trying to be a settings screen. */ +/* Step 3 of the connect flow: what the round trip to Stirling came back with. + + Was a standalone page, centred with its own margins and max-width. It is now a + step body inside FlowModal, which already owns the dialog's width, padding and + spacing, so this only stacks its own blocks. */ .portal-connect-callback { display: flex; flex-direction: column; - align-items: center; - gap: 1rem; - max-width: 30rem; - margin: 4rem auto; - padding: 0 1rem; - text-align: center; -} - -.portal-connect-callback > * { - width: 100%; -} - -/* The button is the one thing that should not stretch to the banner's width. */ -.portal-connect-callback button { - width: auto; + gap: 0.75rem; } .portal-connect-callback p { @@ -30,3 +19,11 @@ .portal-connect-callback__note { font-size: 0.8125rem; } + +/* Only "working" is centred: a spinner with a line under it, which has nothing to + align against. */ +.portal-connect-callback--working { + align-items: center; + gap: 0.625rem; + padding: 1.5rem 0; +} diff --git a/frontend/editor/src/portal/views/ConnectCallback.test.tsx b/frontend/editor/src/portal/views/ConnectCallback.test.tsx index bdadee03e4..b934e11cd7 100644 --- a/frontend/editor/src/portal/views/ConnectCallback.test.tsx +++ b/frontend/editor/src/portal/views/ConnectCallback.test.tsx @@ -2,10 +2,10 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; import { act, render, waitFor } from "@testing-library/react"; import { MemoryRouter, Route, Routes } from "react-router-dom"; import { MantineProvider } from "@mantine/core"; +import { UIProvider, useUI } from "@portal/contexts/UIContext"; +import type { ConnectOutcome } from "@portal/components/account-link/ConnectCallbackView"; -/** - * The callback handles a live session token in a URL fragment, so the behaviour worth pinning is what it does with it: strip it immediately, refuse anything it cannot verify, and keep the two outcomes (SaaS sign-in, server link) independent of each other. - */ +/** A live session token rides in the fragment: strip it at once, refuse what cannot be verified. */ const { completeConnect, startConnect, setSession, refresh } = vi.hoisted( () => ({ completeConnect: vi.fn(), @@ -32,19 +32,38 @@ function landOn(fragment: string) { window.history.replaceState(null, "", `/account-link/callback${fragment}`); } -/** - * Route and host together: the route reads the fragment, the portal renders the - * outcome. Exercising them apart would test the hand-off rather than the flow. - */ +/** Stands in for the dialog that consumes the outcome. */ +let published: ConnectOutcome[] = []; + +function OutcomeSpy() { + const { connectOutcome } = useUI(); + if ( + connectOutcome && + published[published.length - 1]?.state !== connectOutcome.state + ) { + published.push(connectOutcome); + } + return null; +} + +const lastOutcome = () => published[published.length - 1]; + +/** Route and host together: apart, this would test the hand-off rather than the flow. */ function renderFlow() { return render( - - - } /> - } /> - + + + + + } + /> + } /> + + , ); @@ -53,6 +72,7 @@ function renderFlow() { describe("account-link callback", () => { beforeEach(() => { vi.clearAllMocks(); + published = []; completeConnect.mockResolvedValue({ phase: "LINKED", authorizeUrl: null, @@ -67,8 +87,7 @@ describe("account-link callback", () => { renderFlow(); - // Synchronous, before any await: the fragment must not survive long enough - // to be read from the address bar or land in a history entry. + // Before any await: the fragment must not reach the address bar or a history entry. expect(window.location.hash).toBe(""); await waitFor(() => expect(completeConnect).toHaveBeenCalled()); }); @@ -109,8 +128,7 @@ describe("account-link callback", () => { renderFlow(); - // The two outcomes are independent: a failed sign-in must not strand the - // server unlinked. + // Independent outcomes: a failed sign-in must not strand the server unlinked. await waitFor(() => expect(completeConnect).toHaveBeenCalledWith(NONCE)); }); @@ -131,6 +149,18 @@ describe("account-link callback", () => { await waitFor(() => expect(window.location.hash).toBe("")); expect(completeConnect).not.toHaveBeenCalled(); expect(setSession).not.toHaveBeenCalled(); + await waitFor(() => expect(lastOutcome()?.state).toBe("malformed")); + expect(lastOutcome()?.reclaim).toBeUndefined(); + }); + + it("hands the result to the dialog rather than rendering its own", async () => { + landOn(`#type=link&nonce=${NONCE}&access_token=at&refresh_token=rt`); + + const { container } = renderFlow(); + + await waitFor(() => expect(lastOutcome()?.state).toBe("linked")); + expect(lastOutcome()?.sessionRestored).toBe(true); + expect(container.querySelector(".portal-connect-callback")).toBeNull(); }); it("refuses a fragment that is not a link response", async () => { @@ -160,16 +190,30 @@ describe("account-link callback", () => { teamId: null, }); - const { getAllByRole } = renderFlow(); + renderFlow(); await waitFor(() => expect(completeConnect).toHaveBeenCalledTimes(1)); - // Last button, not the only one: the modal shell contributes a close button. - const buttons = getAllByRole("button"); - act(() => buttons[buttons.length - 1].click()); + await waitFor(() => expect(lastOutcome()?.state).toBe("retry")); - // Retries the existing handshake; starting a new one would waste the - // approval a human just gave. + act(() => lastOutcome()!.reclaim!()); + + // Re-claims rather than opening a new handshake, which would spend a leader's approval. await waitFor(() => expect(completeConnect).toHaveBeenCalledTimes(2)); expect(startConnect).not.toHaveBeenCalled(); }); + + it("gives a spent handshake no re-claim, so the dialog asks for a new one", async () => { + landOn(`#type=link&nonce=${NONCE}`); + completeConnect.mockResolvedValue({ + phase: "EXPIRED", + authorizeUrl: null, + secondsRemaining: null, + teamId: null, + }); + + renderFlow(); + + await waitFor(() => expect(lastOutcome()?.state).toBe("expired")); + expect(lastOutcome()?.reclaim).toBeUndefined(); + }); }); diff --git a/frontend/editor/src/portal/views/Integrations.test.tsx b/frontend/editor/src/portal/views/Integrations.test.tsx index 8c4e684bfa..acf1f1b1b1 100644 --- a/frontend/editor/src/portal/views/Integrations.test.tsx +++ b/frontend/editor/src/portal/views/Integrations.test.tsx @@ -13,6 +13,16 @@ import type { IntegrationConfig } from "@portal/api/integrations"; const render = (ui: Parameters[0]) => baseRender(ui, { wrapper: MantineProvider }); +vi.mock("@portal/hooks/useConnectGate", () => ({ + useConnectGate: () => ({ + gated: false, + loading: false, + available: false, + connect: vi.fn(), + guard: (fn: unknown) => fn, + }), +})); + vi.mock("react-i18next", () => ({ useTranslation: () => ({ t: (key: string) => key, diff --git a/frontend/editor/src/portal/views/Integrations.tsx b/frontend/editor/src/portal/views/Integrations.tsx index 793c21f880..fc40feb1e9 100644 --- a/frontend/editor/src/portal/views/Integrations.tsx +++ b/frontend/editor/src/portal/views/Integrations.tsx @@ -31,6 +31,7 @@ import { } from "@portal/components/sources/connectionTypes"; import { STEP_OPERATIONS } from "@portal/components/policies/stepOperations"; import { COMING_SOON_SOURCE_TYPES } from "@portal/components/sources/sourceTypes"; +import { useConnectGate } from "@portal/hooks/useConnectGate"; import "@portal/theme/surface.css"; import "@portal/views/Integrations.css"; @@ -94,6 +95,7 @@ type IntegrationRow = { export function Integrations() { const { t } = useTranslation(); + const { guard } = useConnectGate(); const [connections, setConnections] = useState( null, ); @@ -212,13 +214,23 @@ export function Integrations() { return counts; }, [catalogue]); - const openCreate = useCallback((typeId: string) => { - setModal({ open: true, editing: null, fixedTypeId: typeId }); - }, []); + // Connecting an integration and editing one both need a linked account. Memoised + // because both land in the row-building useMemo deps below. + const openCreate = useMemo( + () => + guard((typeId: string) => { + setModal({ open: true, editing: null, fixedTypeId: typeId }); + }), + [guard], + ); - const openEdit = useCallback((connection: IntegrationConfig) => { - setModal({ open: true, editing: connection }); - }, []); + const openEdit = useMemo( + () => + guard((connection: IntegrationConfig) => { + setModal({ open: true, editing: connection }); + }), + [guard], + ); const remove = useCallback( async (connection: IntegrationConfig) => { diff --git a/frontend/editor/src/portal/views/Pipelines.gated.test.tsx b/frontend/editor/src/portal/views/Pipelines.gated.test.tsx new file mode 100644 index 0000000000..993edc876b --- /dev/null +++ b/frontend/editor/src/portal/views/Pipelines.gated.test.tsx @@ -0,0 +1,117 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { fireEvent, render, screen } from "@testing-library/react"; +import { MemoryRouter, Route, Routes } from "react-router-dom"; +import { PortalViewProviders } from "@portal/test/TestQueryProvider"; + +/** + * The page must look exactly as it always does: the ask is a dialog on the attempt, not a lock + * screen in place of the feature. The route half is what a later link cannot walk around. + */ +const { connect } = vi.hoisted(() => ({ connect: vi.fn() })); + +vi.mock("@portal/hooks/useConnectGate", () => ({ + useConnectGate: () => ({ + gated: true, + loading: false, + available: true, + connect, + guard: + (_action: (...args: A) => void) => + () => + connect(), + }), +})); + +vi.mock("react-i18next", () => ({ + useTranslation: () => ({ + t: (key: string) => key, + i18n: { changeLanguage: vi.fn() }, + }), +})); + +const fetchPipelines = vi.fn(); +vi.mock("@portal/api/pipelines", () => ({ + fetchPipelines: () => fetchPipelines(), +})); + +import { Pipelines } from "@portal/views/Pipelines"; +import { ConnectGuardedRoute } from "@portal/components/account-link/ConnectGuardedRoute"; + +const PIPELINE = { + id: "plc-1", + name: "Redact claims", + enabled: true, + status: "active", + trigger: "schedule", + sources: [{ id: "src-claims", name: "Claims intake" }], + steps: ["/api/v1/security/auto-redact"], + output: "inline", + owner: "security@acme.com", +}; + +function renderAt(initial: string) { + return render( + + + + } /> + +
builder
+ + } + /> +
+
+
, + ); +} + +describe("Pipelines when the account is not connected", () => { + beforeEach(() => { + connect.mockReset(); + fetchPipelines.mockReset(); + }); + + it("leaves the empty state exactly as it is", async () => { + fetchPipelines.mockResolvedValue({ kpis: [], pipelines: [] }); + renderAt("/processor/pipelines"); + expect( + await screen.findByText("portal.pipelines.empty.title"), + ).toBeInTheDocument(); + }); + + it("still lists pipelines that already exist", async () => { + fetchPipelines.mockResolvedValue({ kpis: [], pipelines: [PIPELINE] }); + renderAt("/processor/pipelines"); + expect(await screen.findByText("Redact claims")).toBeInTheDocument(); + }); + + it("asks to connect instead of opening the builder", async () => { + fetchPipelines.mockResolvedValue({ kpis: [], pipelines: [] }); + renderAt("/processor/pipelines"); + await screen.findByText("portal.pipelines.empty.title"); + fireEvent.click(screen.getByText("portal.pipelines.actions.newPipeline")); + expect(connect).toHaveBeenCalled(); + expect(screen.queryByText("builder")).toBeNull(); + }); + + it("asks to connect instead of opening an existing pipeline", async () => { + fetchPipelines.mockResolvedValue({ kpis: [], pipelines: [PIPELINE] }); + renderAt("/processor/pipelines"); + fireEvent.click(await screen.findByText("Redact claims")); + expect(connect).toHaveBeenCalled(); + }); + + it("turns away a direct arrival at the builder, however it was reached", async () => { + fetchPipelines.mockResolvedValue({ kpis: [], pipelines: [] }); + renderAt("/processor/pipelines/new"); + expect( + await screen.findByText("portal.pipelines.empty.title"), + ).toBeInTheDocument(); + expect(screen.queryByText("builder")).toBeNull(); + expect(connect).toHaveBeenCalled(); + }); +}); diff --git a/frontend/editor/src/portal/views/Pipelines.test.tsx b/frontend/editor/src/portal/views/Pipelines.test.tsx index 655abf62cb..250722706e 100644 --- a/frontend/editor/src/portal/views/Pipelines.test.tsx +++ b/frontend/editor/src/portal/views/Pipelines.test.tsx @@ -14,6 +14,16 @@ const render = ( options?: Parameters[1], ) => baseRender(ui, { wrapper: PortalTestProviders, ...options }); +vi.mock("@portal/hooks/useConnectGate", () => ({ + useConnectGate: () => ({ + gated: false, + loading: false, + available: false, + connect: vi.fn(), + guard: (fn: unknown) => fn, + }), +})); + // Deterministic i18n: keys returned verbatim. vi.mock("react-i18next", () => ({ useTranslation: () => ({ diff --git a/frontend/editor/src/portal/views/Pipelines.tsx b/frontend/editor/src/portal/views/Pipelines.tsx index f2203de972..6311e3c438 100644 --- a/frontend/editor/src/portal/views/Pipelines.tsx +++ b/frontend/editor/src/portal/views/Pipelines.tsx @@ -9,11 +9,13 @@ import { VIEW_PATHS, toPortalPath } from "@portal/contexts/ViewContext"; import { PipelinesIcon } from "@portal/components/icons"; import { KpiStrip } from "@portal/components/pipelines/KpiStrip"; import { PipelinesTable } from "@portal/components/pipelines/PipelinesTable"; +import { useConnectGate } from "@portal/hooks/useConnectGate"; import "@portal/views/Pipelines.css"; export function Pipelines() { const { t } = useTranslation(); const navigate = useNavigate(); + const { guard } = useConnectGate(); const state = usePipelines(); const { data, loading } = state; const { isLoading } = useSectionFlags(state); @@ -26,13 +28,18 @@ export function Pipelines() { // the loading and empty states don't flash a row of placeholder cards. const hasPipelines = pipelines.length > 0; - const openCreate = () => - navigate(`${toPortalPath(VIEW_PATHS.pipelines)}/new`); - const connectSource = () => - navigate(`${toPortalPath(VIEW_PATHS.sources)}/new`); + // Building and editing a pipeline both need a linked account, so both ask for one first. + const openCreate = guard(() => + navigate(`${toPortalPath(VIEW_PATHS.pipelines)}/new`), + ); + // Guarded in its own right so the ask happens here rather than after a pointless hop to Sources. + const connectSource = guard(() => + navigate(`${toPortalPath(VIEW_PATHS.sources)}/new`), + ); // A row opens that pipeline's own page (view / edit / run / delete live there). - const openPipeline = (pipeline: PipelineView) => - navigate(`${toPortalPath(VIEW_PATHS.pipelines)}/${pipeline.id}`); + const openPipeline = guard((pipeline: PipelineView) => + navigate(`${toPortalPath(VIEW_PATHS.pipelines)}/${pipeline.id}`), + ); return (
diff --git a/frontend/editor/src/portal/views/Policies.tsx b/frontend/editor/src/portal/views/Policies.tsx index 9e316852fd..b8cb42112a 100644 --- a/frontend/editor/src/portal/views/Policies.tsx +++ b/frontend/editor/src/portal/views/Policies.tsx @@ -1,4 +1,4 @@ -import { useCallback, useEffect, useMemo, useState } from "react"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useQueryClient } from "@tanstack/react-query"; import { useSearchParams } from "react-router-dom"; import { useTranslation } from "react-i18next"; @@ -23,11 +23,13 @@ import { PolicyCatalogueTable } from "@portal/components/policies/PolicyCatalogu import { PolicyDetailPanel } from "@portal/components/policies/PolicyDetailPanel"; import { PolicySetupWizard } from "@portal/components/policies/PolicySetupWizard"; import { useAiEngineEnabled } from "@portal/hooks/useAiEngineEnabled"; +import { useConnectGate } from "@portal/hooks/useConnectGate"; import "@portal/views/Policies.css"; export function Policies() { const { t } = useTranslation(); const queryClient = useQueryClient(); + const { gated, connect } = useConnectGate(); const state = usePoliciesOverview(); const { data, loading, error: fetchError } = state; const { isLoading } = useSectionFlags(state); @@ -38,18 +40,27 @@ export function Policies() { const [pageError, setPageError] = useState(null); const [searchParams, setSearchParams] = useSearchParams(); + // Held in a ref so the effects below do not re-run on its identity. They write back to the URL, + // so a callback that changes each render would loop: strip the param, re-render, run again. + const connectRef = useRef(connect); + connectRef.current = connect; + + // Deep link from the Home processor flow. It sets the wizard directly rather than going through + // openEntry, so the gate belongs here too: guarding openEntry alone would leave ?setup= as a way + // past it. useEffect(() => { const setupId = searchParams.get("setup"); if (!setupId || !data) return; const entry = data.catalogue.find((e) => e.category.id === setupId); if (entry && !entry.category.comingSoon) { - if (entry.policy) setDetail(entry); + if (gated) connectRef.current(); + else if (entry.policy) setDetail(entry); else setWizard(entry); } const next = new URLSearchParams(searchParams); next.delete("setup"); setSearchParams(next, { replace: true }); - }, [searchParams, data, setSearchParams]); + }, [searchParams, data, setSearchParams, gated]); const { enabled: aiEngineEnabled, loading: aiEngineLoading } = useAiEngineEnabled(); @@ -95,6 +106,13 @@ export function Policies() { const openEntry = useCallback( (entry: CatalogueEntry) => { + // Ask rather than open an editor whose save would fail; viewing the catalogue stays open. + // Via the ref so this keeps its identity: the deep-link effect depends on it and writes the + // URL back, which would otherwise loop. + if (gated) { + connectRef.current(); + return; + } // Block setup of an AI-required policy until the engine is confirmed on (so a // click during the app-config load can't open a wizard for a disabled // feature); a configured policy stays openable so it can be paused/deleted. @@ -103,7 +121,7 @@ export function Policies() { if (entry.policy) setDetail(entry); else setWizard(entry); }, - [aiEngineEnabled], + [aiEngineEnabled, gated], ); // Open a category passed as ?category= (deep link from the super diff --git a/frontend/editor/src/portal/views/Sources.gated.test.tsx b/frontend/editor/src/portal/views/Sources.gated.test.tsx new file mode 100644 index 0000000000..944ab8c37c --- /dev/null +++ b/frontend/editor/src/portal/views/Sources.gated.test.tsx @@ -0,0 +1,111 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { render, screen } from "@testing-library/react"; +import { MemoryRouter, Route, Routes } from "react-router-dom"; +import { PortalViewProviders } from "@portal/test/TestQueryProvider"; + +/** + * The deep link into the create flow, which the gate has to cover in its own right. + * + * `?new=1` opens the modal from an effect rather than through the click handler, so guarding + * openCreate does nothing for it. Both the Documents review queue and the pipelines empty state + * arrive here that way, so each is a way past the gate unless the deep link is guarded too. + */ +const { connect } = vi.hoisted(() => ({ connect: vi.fn() })); +const gate = { gated: true }; + +vi.mock("@portal/hooks/useConnectGate", () => ({ + useConnectGate: () => ({ + gated: gate.gated, + loading: false, + available: true, + connect, + guard: + (action: (...args: A) => void) => + (...args: A) => { + if (gate.gated) connect(); + else action(...args); + }, + }), +})); + +vi.mock("react-i18next", () => ({ + useTranslation: () => ({ + t: (key: string) => key, + i18n: { changeLanguage: vi.fn() }, + }), +})); + +const fetchSources = vi.fn(); +vi.mock("@portal/api/sources", () => ({ + fetchSources: () => fetchSources(), + fetchSource: vi.fn(), + createSource: vi.fn(), + deleteSource: vi.fn(), + isFolderAccessDeniedError: () => false, +})); +vi.mock("@portal/api/integrations", () => ({ + fetchIntegrations: () => Promise.resolve([]), + fetchIntegrationCapabilities: () => Promise.resolve({ customApi: false }), + fetchS3Connections: () => Promise.resolve([]), + deleteIntegration: vi.fn(), +})); + +import { Sources } from "@portal/views/Sources"; + +const EDITOR_ROW = { + id: "editor", + name: "Editor", + type: "editor", + status: "active", + referenceCount: 0, + referencingPolicies: [], + config: [], + docsTotal: 0, + docs24h: 0, + docs30d: 0, +}; + +const renderAt = (initial: string) => + render( + + + + } /> + + + , + ); + +describe("Sources deep link when the account is not connected", () => { + beforeEach(() => { + connect.mockReset(); + gate.gated = true; + fetchSources.mockReset(); + fetchSources.mockResolvedValue({ kpis: [], sources: [EDITOR_ROW] }); + }); + + // Renders only once the fetch resolves, so finding it is also the await. + const LIST = "portal.sources.table.source"; + const MODAL = "portal.sources.builder.createTitle"; + + it("asks to connect instead of opening the create modal", async () => { + renderAt("/processor/sources?new=1"); + expect(await screen.findByText(LIST)).toBeInTheDocument(); + expect(connect).toHaveBeenCalled(); + expect(screen.queryByText(MODAL)).toBeNull(); + }); + + it("leaves the page looking exactly as it always does", async () => { + renderAt("/processor/sources"); + expect(await screen.findByText(LIST)).toBeInTheDocument(); + expect(connect).not.toHaveBeenCalled(); + }); + + it("still honours the deep link once connected", async () => { + gate.gated = false; + renderAt("/processor/sources?new=1"); + expect(await screen.findByText(LIST)).toBeInTheDocument(); + expect(connect).not.toHaveBeenCalled(); + expect(await screen.findByText(MODAL)).toBeInTheDocument(); + }); +}); diff --git a/frontend/editor/src/portal/views/Sources.test.tsx b/frontend/editor/src/portal/views/Sources.test.tsx index 166d4016fc..3891f1955d 100644 --- a/frontend/editor/src/portal/views/Sources.test.tsx +++ b/frontend/editor/src/portal/views/Sources.test.tsx @@ -22,6 +22,16 @@ const Providers = ({ children }: { children: ReactNode }) => ( const render = (ui: Parameters[0]) => baseRender(ui, { wrapper: Providers }); +vi.mock("@portal/hooks/useConnectGate", () => ({ + useConnectGate: () => ({ + gated: false, + loading: false, + available: false, + connect: vi.fn(), + guard: (fn: unknown) => fn, + }), +})); + // Deterministic i18n: keys returned verbatim. vi.mock("react-i18next", () => ({ useTranslation: () => ({ diff --git a/frontend/editor/src/portal/views/Sources.tsx b/frontend/editor/src/portal/views/Sources.tsx index f676b4f3db..bb740f5d69 100644 --- a/frontend/editor/src/portal/views/Sources.tsx +++ b/frontend/editor/src/portal/views/Sources.tsx @@ -1,4 +1,4 @@ -import { useEffect, useState } from "react"; +import { useEffect, useRef, useState } from "react"; import { useTranslation } from "react-i18next"; import { Navigate, useSearchParams } from "react-router-dom"; import AddRoundedIcon from "@mui/icons-material/AddRounded"; @@ -10,11 +10,13 @@ import { VIEW_PATHS, toPortalPath } from "@portal/contexts/ViewContext"; import { KpiStrip } from "@portal/components/sources/KpiStrip"; import { SourcesTable } from "@portal/components/sources/SourcesTable"; import { SourceModal } from "@portal/components/sources/SourceModal"; +import { useConnectGate } from "@portal/hooks/useConnectGate"; import "@portal/views/Sources.css"; export function Sources() { const { t } = useTranslation(); const [searchParams, setSearchParams] = useSearchParams(); + const { guard, gated, connect } = useConnectGate(); const state = useSources(); const { data, loading } = state; @@ -27,13 +29,20 @@ export function Sources() { sourceId: string | null; }>({ open: false, sourceId: null }); + // Ref so the effect does not loop: it writes the param back, which would re-run it. + const connectRef = useRef(connect); + connectRef.current = connect; + + // Sets the modal directly, so it needs the gate in its own right: guarding openCreate would + // leave ?new=1 as a way past it. useEffect(() => { if (searchParams.get("new") !== "1") return; - setModal({ open: true, sourceId: null }); + if (gated) connectRef.current(); + else setModal({ open: true, sourceId: null }); const next = new URLSearchParams(searchParams); next.delete("new"); setSearchParams(next, { replace: true }); - }, [searchParams, setSearchParams]); + }, [searchParams, setSearchParams, gated]); const sources = data?.sources ?? []; @@ -42,9 +51,11 @@ export function Sources() { const configuredCount = sources.filter((s) => s.type !== "editor").length; const showKpis = isLoading || configuredCount > 0; - const openCreate = () => setModal({ open: true, sourceId: null }); - const openSource = (source: SourceView) => - setModal({ open: true, sourceId: source.id }); + // Connecting a source and editing one both need a linked account. + const openCreate = guard(() => setModal({ open: true, sourceId: null })); + const openSource = guard((source: SourceView) => + setModal({ open: true, sourceId: source.id }), + ); // The Connections tab moved to its own Integrations view. if (searchParams.get("tab") === "connections") { diff --git a/frontend/editor/src/portal/views/Users.caching.test.tsx b/frontend/editor/src/portal/views/Users.caching.test.tsx index a619d883b7..1a9e3e28c6 100644 --- a/frontend/editor/src/portal/views/Users.caching.test.tsx +++ b/frontend/editor/src/portal/views/Users.caching.test.tsx @@ -29,6 +29,16 @@ import { qk } from "@portal/queries/keys"; * one /team/my resolve. Same SaaS mocks as Users.saas.test.tsx. */ +vi.mock("@portal/hooks/useConnectGate", () => ({ + useConnectGate: () => ({ + gated: false, + loading: false, + available: false, + connect: vi.fn(), + guard: (fn: unknown) => fn, + }), +})); + vi.mock("@app/auth", () => ({ getStoredToken: () => null, clearStoredToken: vi.fn(), diff --git a/frontend/editor/src/portal/views/Users.gated.test.tsx b/frontend/editor/src/portal/views/Users.gated.test.tsx new file mode 100644 index 0000000000..b5286964fc --- /dev/null +++ b/frontend/editor/src/portal/views/Users.gated.test.tsx @@ -0,0 +1,96 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { render, screen } from "@testing-library/react"; +import { MemoryRouter, Route, Routes } from "react-router-dom"; +import { PortalViewProviders } from "@portal/test/TestQueryProvider"; + +/** + * `?invite` opens the modal from an effect, so guarding openInvite did nothing for it. Same hole as + * Sources' `?new=1`, and it survived that fix. + */ +const { connect } = vi.hoisted(() => ({ connect: vi.fn() })); +const gate = { gated: true }; + +vi.mock("@portal/hooks/useConnectGate", () => ({ + useConnectGate: () => ({ + gated: gate.gated, + loading: false, + available: true, + connect, + guard: + (action: (...args: A) => void) => + (...args: A) => { + if (gate.gated) connect(); + else action(...args); + }, + }), +})); + +vi.mock("react-i18next", () => ({ + useTranslation: () => ({ + t: (key: string) => key, + i18n: { changeLanguage: vi.fn() }, + }), +})); + +vi.mock("@portal/contexts/TierContext", () => ({ + useTier: () => ({ tier: "pro" }), +})); +vi.mock("@app/auth", () => ({ + getStoredToken: () => null, + clearStoredToken: vi.fn(), +})); +vi.mock("@app/auth/supabase/supabaseClient", () => ({ + getSupabaseClient: () => null, + configureSupabase: vi.fn(), +})); +vi.mock("@portal/auth/saasSupabase", () => ({ ensureSaasSupabase: vi.fn() })); + +vi.mock("@portal/hooks/useUsersData", () => ({ + useUsersData: () => ({ + usersState: { data: [], loading: false, error: null }, + grantsState: { data: [], loading: false, error: null }, + teamsState: { data: [], loading: false, error: null }, + authState: { data: null, loading: false, error: null }, + refresh: vi.fn(), + }), +})); + +import { Users } from "@portal/views/Users"; + +const INVITE_MODAL = "users.invite.title"; + +const renderAt = (initial: string) => + render( + + + + } /> + + + , + ); + +describe("Users deep link when the account is not connected", () => { + beforeEach(() => { + connect.mockReset(); + gate.gated = true; + }); + + it("asks to connect instead of opening the invite modal", () => { + renderAt("/processor/users?invite"); + expect(connect).toHaveBeenCalled(); + expect(screen.queryByText(INVITE_MODAL)).toBeNull(); + }); + + it("leaves the page alone when there is no deep link", () => { + renderAt("/processor/users"); + expect(connect).not.toHaveBeenCalled(); + }); + + it("still honours the deep link once connected", () => { + gate.gated = false; + renderAt("/processor/users?invite"); + expect(connect).not.toHaveBeenCalled(); + expect(screen.getByText(INVITE_MODAL)).toBeInTheDocument(); + }); +}); diff --git a/frontend/editor/src/portal/views/Users.saas.test.tsx b/frontend/editor/src/portal/views/Users.saas.test.tsx index 5313857c2c..1ea72e5a10 100644 --- a/frontend/editor/src/portal/views/Users.saas.test.tsx +++ b/frontend/editor/src/portal/views/Users.saas.test.tsx @@ -32,6 +32,16 @@ import { */ // Keep apiClient.local's transport hermetic (no real token / Supabase at import). +vi.mock("@portal/hooks/useConnectGate", () => ({ + useConnectGate: () => ({ + gated: false, + loading: false, + available: false, + connect: vi.fn(), + guard: (fn: unknown) => fn, + }), +})); + vi.mock("@app/auth", () => ({ getStoredToken: () => null, clearStoredToken: vi.fn(), diff --git a/frontend/editor/src/portal/views/Users.tsx b/frontend/editor/src/portal/views/Users.tsx index 79c6c11611..95b51091f0 100644 --- a/frontend/editor/src/portal/views/Users.tsx +++ b/frontend/editor/src/portal/views/Users.tsx @@ -21,6 +21,7 @@ import { import { deleteTeam as apiDeleteTeam } from "@portal/api/teams"; import { errorMessage } from "@portal/api/http"; import { usersCapabilities as caps } from "@app/portal/usersCapabilities"; +import { useConnectGate } from "@portal/hooks/useConnectGate"; import { UsersDirectory } from "@portal/components/users/UsersDirectory"; import { PendingInvitations } from "@portal/components/users/PendingInvitations"; import { InviteMemberModal } from "@portal/components/users/InviteMemberModal"; @@ -46,6 +47,7 @@ interface Confirm { */ export function Users() { const { t } = useTranslation(); + const { guard, gated, connect } = useConnectGate(); const { usersState, grantsState, teamsState, authState, refresh } = useUsersData(); @@ -62,13 +64,20 @@ export function Users() { const [confirm, setConfirm] = useState(null); const [searchParams, setSearchParams] = useSearchParams(); + + // Ref so the effect does not loop: it writes the param back, which would re-run it. + const connectRef = useRef(connect); + connectRef.current = connect; + + // Sets the modal directly, so it needs the gate in its own right. useEffect(() => { if (searchParams.get("invite") === null) return; - setInviteOpen(true); + if (gated) connectRef.current(); + else setInviteOpen(true); const next = new URLSearchParams(searchParams); next.delete("invite"); setSearchParams(next, { replace: true }); - }, [searchParams, setSearchParams]); + }, [searchParams, setSearchParams, gated]); // Scroll to and flash the row for ?member= (deep link from the super // search), once the roster has rendered; then strip the param. Scoped to the @@ -194,10 +203,12 @@ export function Users() { if (!grant) return; run(() => revokeGrant(grant.id)); } - function openInvite(teamId: number | null) { + // Teams need a linked account, so inviting or creating one asks for the connection first. + const openInvite = guard((teamId: number | null) => { setInviteTeamId(teamId); setInviteOpen(true); - } + }); + const openNewTeam = guard(() => setNewTeamOpen(true)); // Kebab actions function toggleEnabled(member: Member) { @@ -282,11 +293,7 @@ export function Users() {
{caps.createTeam && ( - )} diff --git a/frontend/editor/src/saas/routes/ConnectApproveView.tsx b/frontend/editor/src/saas/routes/ConnectApproveView.tsx index 26a2fe1150..c89a27ef49 100644 --- a/frontend/editor/src/saas/routes/ConnectApproveView.tsx +++ b/frontend/editor/src/saas/routes/ConnectApproveView.tsx @@ -1,8 +1,51 @@ -import { useState } from "react"; +import { useState, type ReactNode } from "react"; import { useTranslation } from "@app/hooks/useTranslation"; import { Banner, Button, Checkbox, Spinner } from "@app/ui"; import { LocalIcon } from "@app/components/shared/LocalIcon"; import { Tooltip } from "@app/components/shared/Tooltip"; +import { StepModalHeader } from "@portal/components/shared/StepModalHeader"; + +/** + * This page is step 2 of a flow that started on the instance, so it wears the same chrome: the admin + * is being asked for a security decision by what would otherwise look like a different product. + * + *

TODO: re-auth still wears the first link's copy and consent checkbox, which asks the approver + * to agree to a binding that already exists. + */ +const TOTAL_STEPS = 3; + +function ApproveShell({ + title, + stepped, + children, +}: { + title: string; + /** Re-auth is one step on the instance side, so counting to three here would describe nothing. */ + stepped: boolean; + children: ReactNode; +}) { + const { t } = useTranslation(); + return ( +

+ {/* No onClose: this is a page, so there is nowhere to close back to. */} + + {children} +
+ ); +} export type ApprovePhase = | "loading" @@ -16,6 +59,8 @@ export interface PendingConnect { requestId: string; callbackOrigin: string; insecureTransport: boolean; + /** REAUTH cannot rebind: the team is pinned from the device credential at request time. */ + mode?: "LINK" | "REAUTH"; } export interface ConnectApproveViewProps { @@ -44,23 +89,31 @@ export function ConnectApproveView({ // Gates the primary action: anyone can create a request, so the approver reading // the address is the only thing between one and a linked team. const [acknowledged, setAcknowledged] = useState(false); + const stepped = pending?.mode !== "REAUTH"; if (phase === "loading" || phase === "redirecting") { return ( -
- -

- {phase === "redirecting" + -

+ : t("connect.loading", "Checking this request.") + } + > +
+ +
+ ); } if (phase === "notFound") { return ( -
+ -
+ ); } if (phase === "declined") { return ( -
+ -
+ ); } return ( -
-

- {t("connect.confirm.title", "Connect this server?")} -

+

{t( "connect.confirm.lead", @@ -177,6 +233,6 @@ export function ConnectApproveView({ {t("connect.confirm.approve", "Connect server")}

-
+ ); } diff --git a/frontend/editor/src/saas/routes/connect.css b/frontend/editor/src/saas/routes/connect.css index 5a709abfbe..391f9dad28 100644 --- a/frontend/editor/src/saas/routes/connect.css +++ b/frontend/editor/src/saas/routes/connect.css @@ -8,11 +8,10 @@ text-align: left; } -.saas-connect__title { - margin: 0; - font-size: 1.25rem; - font-weight: 600; - color: var(--c-text); +.saas-connect__waiting { + display: flex; + justify-content: center; + padding: 1.5rem 0; } .saas-connect__lead { From 4ef2e3811c71af7b2c39da98d82e6ce5b03af3af Mon Sep 17 00:00:00 2001 From: ConnorYoh <40631091+ConnorYoh@users.noreply.github.com> Date: Tue, 1 Sep 2026 12:35:57 +0000 Subject: [PATCH 32/37] ci(preview): give PR previews the Stirling account config they need to link (#7728) Add CI steps to enable PR deploy servers to link to prod saas. This will allow pr testing of payment flows, usage of real credits etc --- .github/workflows/PR-Auto-Deploy-V2.yml | 46 +++++++++++++++++++++++++ docker/embedded/Dockerfile | 14 ++++++++ 2 files changed, 60 insertions(+) diff --git a/.github/workflows/PR-Auto-Deploy-V2.yml b/.github/workflows/PR-Auto-Deploy-V2.yml index c55d70fd4e..b4bbd111e1 100644 --- a/.github/workflows/PR-Auto-Deploy-V2.yml +++ b/.github/workflows/PR-Auto-Deploy-V2.yml @@ -220,6 +220,42 @@ jobs: echo "app_short=${APP_HASH:0:8}" >> $GITHUB_OUTPUT fi + # The Stirling account previews connect to. Derived from the ref rather than stored as a URL + # so it cannot drift from the key: a mismatched pair is accepted by the browser and rejected + # by Supabase, surfacing much later as "session expired" on Usage rather than at sign-in. + # Secret only to match Saas-Dev-Deploy.yml, which owns the same value; a project ref is not + # itself sensitive, which is why SAAS_API_BASE_URL next to it is a plain variable. + - name: Resolve Stirling account config + id: saas + env: + PROJECT_REF: ${{ secrets.SAAS_DB_PROJECT_REF }} + API_BASE_OVERRIDE: ${{ vars.SAAS_API_BASE_URL }} + run: | + # Set, this is the one value both halves use: the browser's portal reads and the backend's + # register/entitlement calls have to land on the same SaaS, and nothing checks that they + # do. Unset, only the backend gets a base, from its own compiled-in default. + API_BASE="${API_BASE_OVERRIDE:-https://stirling.com/app}" + echo "backend_base=${API_BASE}" >> "$GITHUB_OUTPUT" + + if [ -z "${PROJECT_REF}" ]; then + echo "Not configured for this environment: the preview will build without a Stirling" + echo "account, and the connect dialog will say so. To wire one up, set on the" + echo "pr-preview environment the secrets SAAS_DB_PROJECT_REF and" + echo "SAAS_SUPABASE_PUBLISHABLE_KEY, both from the same Supabase project." + echo "supabase_url=" >> "$GITHUB_OUTPUT" + echo "frontend_base=" >> "$GITHUB_OUTPUT" + else + # Only whether, not which: the ref is a secret here, so Actions masks it out of any + # line it appears in, derived URL included. + echo "Stirling account configured, at ${API_BASE}." + echo "supabase_url=https://${PROJECT_REF}.supabase.co" >> "$GITHUB_OUTPUT" + # Deliberately the override and not API_BASE: the backend's default is a subpath URL + # nobody has confirmed answers /api/v1, and prod CORS does not list preview hostnames, + # so portal reads stay off until someone sets a base they have checked. Empty leaves the + # committed .env default alone, which is the clean "not configured" state. + echo "frontend_base=${API_BASE_OVERRIDE}" >> "$GITHUB_OUTPUT" + fi + - name: Check if image exists id: check-image run: | @@ -246,6 +282,9 @@ jobs: build-args: | VERSION_TAG=v2-alpha BUILD_PORTAL=${{ env.BUILD_PORTAL }} + VITE_SUPABASE_URL=${{ steps.saas.outputs.supabase_url }} + VITE_SUPABASE_PUBLISHABLE_DEFAULT_KEY=${{ secrets.SAAS_SUPABASE_PUBLISHABLE_KEY }} + VITE_SAAS_API_URL=${{ steps.saas.outputs.frontend_base }} platforms: linux/amd64 - name: Set up SSH @@ -279,6 +318,13 @@ jobs: environment: DISABLE_ADDITIONAL_FEATURES: "false" STIRLING_BILLING_ACCOUNT_LINK_ENABLED: "true" + STIRLING_BILLING_ACCOUNT_LINK_SAAS_BASE_URL: "${{ steps.saas.outputs.backend_base }}" + # Off so preview traffic never accrues against a real wallet or trips its cap. The + # 402 gate is separate and stays on, so gating is still testable here. + STIRLING_BILLING_ACCOUNT_LINK_METERING_ENABLED: "false" + # Stated rather than inferred from the request: the callback has to come back to the + # preview hostname, not to the container's own :8080 behind this proxy. + SYSTEM_FRONTENDURL: "https://${V2_PORT}.ssl.stirlingpdf.cloud" SECURITY_ENABLELOGIN: "true" SECURITY_INITIALLOGIN_USERNAME: "${TEST_LOGIN_USERNAME}" SECURITY_INITIALLOGIN_PASSWORD: "${TEST_LOGIN_PASSWORD}" diff --git a/docker/embedded/Dockerfile b/docker/embedded/Dockerfile index 80e163dd9a..6f525b2732 100644 --- a/docker/embedded/Dockerfile +++ b/docker/embedded/Dockerfile @@ -48,9 +48,23 @@ ENV STIRLING_FLAVOR=${STIRLING_FLAVOR} # portal or AI layers change; defaults false so normal builds skip the extra app. ARG BUILD_PORTAL=false +# Which Stirling account the portal connects to. Build-time because Vite inlines VITE_* into the +# bundle; there is no runtime override. Empty leaves the committed .env.proprietary defaults, which +# is what an ordinary image wants: no Stirling account and no connect flow. The publishable key is +# client-side by design, not a secret. Pass the URL and the key from the same Supabase project or +# the browser accepts the pair and Supabase rejects it, which surfaces later as "session expired". +ARG VITE_SUPABASE_URL="" +ARG VITE_SUPABASE_PUBLISHABLE_DEFAULT_KEY="" +ARG VITE_SAAS_API_URL="" + # Bundle only the JPDFium native for this image's target arch. ARG TARGETARCH +# Exported only when non-empty: Vite reads process.env ahead of the .env files, so exporting an +# empty value would blank the committed default rather than fall back to it. RUN JPDFIUM_PLATFORM="$([ "$TARGETARCH" = arm64 ] && echo linux-arm64 || echo linux-x64)" && \ + if [ -n "${VITE_SUPABASE_URL}" ]; then export VITE_SUPABASE_URL="${VITE_SUPABASE_URL}"; fi; \ + if [ -n "${VITE_SUPABASE_PUBLISHABLE_DEFAULT_KEY}" ]; then export VITE_SUPABASE_PUBLISHABLE_DEFAULT_KEY="${VITE_SUPABASE_PUBLISHABLE_DEFAULT_KEY}"; fi; \ + if [ -n "${VITE_SAAS_API_URL}" ]; then export VITE_SAAS_API_URL="${VITE_SAAS_API_URL}"; fi; \ STIRLING_FLAVOR=${STIRLING_FLAVOR} \ gradle clean build \ -PbuildWithFrontend=true \ From ceeec53df4b72bd13576cd5146c07adf1e5ba96b Mon Sep 17 00:00:00 2001 From: Anthony Stirling <77850077+Frooodle@users.noreply.github.com> Date: Tue, 1 Sep 2026 13:12:06 +0000 Subject: [PATCH 33/37] Let a pipeline run on the editor, on upload or export (#7581) Redesigns the policies system so that the backend has an understanding of policies running over the Editor. The Editor is not set up as a source for the backend because the backend can't actively get files from it, they come in via the frontend sending them to the backend, so instead pipelines have a specific editor key in them to encode whether the pipeline is triggered on file upload/export in the editor. Also make a big effort in the frontend code towards genericising policy running. Previously, there was specific support in the main policy executor for each policy that it had to run, which was not going to be appropriate long-term, especially when users can run any pipeline in the editor. There's more work needed here for me to really be happy with it but this PR is plenty large on its own and moves it in the right direction. All of the above was required to allow arbitrary user pipelines to run in the editor. This PR makes it so that the user can select Editor as a source in the pipeline creator, along with whether it should run on upload or export. image --------- Co-authored-by: James Brunton --- .../policy/controller/PolicyController.java | 12 +- .../policy/model/EditorConfig.java | 34 ++ .../proprietary/policy/model/Policy.java | 36 ++- .../overview/PolicyOverviewService.java | 6 +- .../DefaultClassificationPolicySeeder.java | 9 +- .../policy/source/SourceOverviewService.java | 10 +- .../policy/store/InProcessPolicyStore.java | 3 +- .../policy/store/JpaPolicyStore.java | 67 +++- .../overview/PolicyOverviewServiceTest.java | 39 +++ ...DefaultClassificationPolicySeederTest.java | 20 +- .../source/SourceOverviewServiceTest.java | 10 +- .../policy/store/JpaPolicyStoreTest.java | 124 ++++++++ .../public/locales/en-US/translation.toml | 9 + .../fileManager/CompactFileDetails.tsx | 3 +- .../components/filesPage/VersionTimeline.tsx | 10 +- .../src/core/components/shared/ToolChain.tsx | 35 +-- .../core/contexts/file/FileReducer.test.ts | 35 ++- .../src/core/contexts/file/FileReducer.ts | 20 +- .../src/core/services/fileStubHelpers.ts | 4 +- .../classification-heuristic-upload.spec.ts | 14 +- .../stubbed/editor-pipeline-auto-run.spec.ts | 85 +++++ frontend/editor/src/core/types/file.ts | 3 + .../src/core/utils/toolOperationLabel.test.ts | 29 ++ .../src/core/utils/toolOperationLabel.ts | 17 + frontend/editor/src/portal/api/pipelines.ts | 2 + frontend/editor/src/portal/api/policies.ts | 7 + .../pipelines/PipelineInputTrigger.tsx | 140 +++++++++ .../pipelines/graph/PipelineGraph.tsx | 6 +- .../components/policies/PolicyDetailPanel.tsx | 16 +- .../components/policies/PolicySetupWizard.tsx | 22 +- .../src/portal/views/PipelineBuilder.css | 12 + .../src/portal/views/PipelineBuilder.test.tsx | 39 +++ .../src/portal/views/PipelineBuilder.tsx | 213 +++++++------ .../policies/PolicyAutoRunController.tsx | 7 +- .../policies/classificationLocalPass.ts | 169 ++++++++++ .../components/policies/policyLocalPass.ts | 34 ++ .../policies/policyRunStore.test.ts | 23 -- .../components/policies/policyRunStore.ts | 22 +- .../policies/useClientSideClassification.ts | 251 --------------- .../policies/usePolicyAutoRun.batch.test.tsx | 129 ++++++-- .../policies/usePolicyAutoRun.chain.test.tsx | 297 ++++++------------ .../usePolicyAutoRun.escalation.test.tsx | 155 --------- .../policies/usePolicyAutoRun.import.test.tsx | 1 + .../policies/usePolicyAutoRun.race.test.tsx | 28 +- .../usePolicyAutoRun.reentry.test.tsx | 1 + .../policies/usePolicyAutoRun.retry.test.tsx | 1 + .../components/policies/usePolicyAutoRun.ts | 158 ++-------- ...test.tsx => usePolicyLocalPasses.test.tsx} | 150 +++++++-- .../policies/usePolicyLocalPasses.ts | 144 +++++++++ .../data/classificationPolicy.test.ts | 96 +++--- .../proprietary/data/classificationPolicy.ts | 60 ++-- .../src/proprietary/hooks/usePolicies.test.ts | 75 ++++- .../src/proprietary/hooks/usePolicies.ts | 22 +- .../src/proprietary/policies/codec.test.ts | 63 +++- .../editor/src/proprietary/policies/codec.ts | 12 +- .../editor/src/proprietary/policies/types.ts | 17 + .../services/policyBackend.test.ts | 135 ++++++++ .../src/proprietary/services/policyBackend.ts | 12 +- .../proprietary/services/policyDispatch.ts | 97 ++++++ .../proprietary/services/policyExport.test.ts | 129 ++++++++ .../src/proprietary/services/policyExport.ts | 38 ++- .../services/policyPipeline.test.ts | 6 +- .../proprietary/services/policyPipeline.ts | 22 +- .../services/policyStorage.test.ts | 37 +++ .../src/proprietary/services/policyStorage.ts | 35 ++- .../editor/src/proprietary/types/policies.ts | 6 + 66 files changed, 2405 insertions(+), 1118 deletions(-) create mode 100644 app/proprietary/src/main/java/stirling/software/proprietary/policy/model/EditorConfig.java create mode 100644 frontend/editor/src/core/tests/stubbed/editor-pipeline-auto-run.spec.ts create mode 100644 frontend/editor/src/core/utils/toolOperationLabel.test.ts create mode 100644 frontend/editor/src/core/utils/toolOperationLabel.ts create mode 100644 frontend/editor/src/portal/components/pipelines/PipelineInputTrigger.tsx create mode 100644 frontend/editor/src/proprietary/components/policies/classificationLocalPass.ts create mode 100644 frontend/editor/src/proprietary/components/policies/policyLocalPass.ts delete mode 100644 frontend/editor/src/proprietary/components/policies/useClientSideClassification.ts delete mode 100644 frontend/editor/src/proprietary/components/policies/usePolicyAutoRun.escalation.test.tsx rename frontend/editor/src/proprietary/components/policies/{useClientSideClassification.test.tsx => usePolicyLocalPasses.test.tsx} (62%) create mode 100644 frontend/editor/src/proprietary/components/policies/usePolicyLocalPasses.ts create mode 100644 frontend/editor/src/proprietary/services/policyBackend.test.ts create mode 100644 frontend/editor/src/proprietary/services/policyDispatch.ts create mode 100644 frontend/editor/src/proprietary/services/policyExport.test.ts diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/policy/controller/PolicyController.java b/app/proprietary/src/main/java/stirling/software/proprietary/policy/controller/PolicyController.java index 5a5a3018b1..3dac4e284e 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/policy/controller/PolicyController.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/policy/controller/PolicyController.java @@ -336,6 +336,15 @@ public class PolicyController { * nothing to check. */ private void requireAccessibleOutput(Policy policy) { + // An editor policy hands its results back to the workspace the file came from. A stored + // destination would send the run to a folder or bucket instead, leaving the editor's copy + // untouched - and the editor's import would then have nothing to collect. + if (policy.editor().allowed() && !policy.outputIds().isEmpty()) { + throw new ResponseStatusException( + HttpStatus.BAD_REQUEST, + "An editor policy delivers back to the editor and can't also have a" + + " destination"); + } for (String outputId : policy.outputIds()) { Source destination = sourceStore @@ -393,7 +402,8 @@ public class PolicyController { policy.steps(), policy.output(), policy.outputIds(), - teamId); + teamId, + policy.editor()); } /** Output secrets never leave the server: reads return the redaction sentinel instead. */ diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/policy/model/EditorConfig.java b/app/proprietary/src/main/java/stirling/software/proprietary/policy/model/EditorConfig.java new file mode 100644 index 0000000000..9b15adea2d --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/proprietary/policy/model/EditorConfig.java @@ -0,0 +1,34 @@ +package stirling.software.proprietary.policy.model; + +/** + * How a policy participates in the editor: it fires in the browser as each file passes through, + * rather than being swept from a stored {@code Source} on a trigger. + * + *

An object rather than a bare flag so the moment it fires ({@code runOn}) travels with the + * decision, and so later editor-only settings have somewhere to live. + * + * @param allowed whether the editor may run this policy at all + * @param runOn which moment it fires on: {@code "upload"} or {@code "export"} + */ +public record EditorConfig(boolean allowed, String runOn) { + + public static final String UPLOAD = "upload"; + public static final String EXPORT = "export"; + + public EditorConfig { + runOn = EXPORT.equals(runOn) ? EXPORT : UPLOAD; + } + + /** Not an editor policy: swept server-side, or run only on demand. */ + public static EditorConfig disabled() { + return new EditorConfig(false, UPLOAD); + } + + public static EditorConfig onUpload() { + return new EditorConfig(true, UPLOAD); + } + + public static EditorConfig onExport() { + return new EditorConfig(true, EXPORT); + } +} diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/policy/model/Policy.java b/app/proprietary/src/main/java/stirling/software/proprietary/policy/model/Policy.java index 14b1eb325c..63b26f380c 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/policy/model/Policy.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/policy/model/Policy.java @@ -1,6 +1,7 @@ package stirling.software.proprietary.policy.model; import java.util.List; +import java.util.Optional; /** * A stored automation: ordered tool steps, input bindings, and output destinations. @@ -24,13 +25,29 @@ public record Policy( List steps, OutputSpec output, List outputIds, - Long teamId) { + Long teamId, + EditorConfig editor) { public Policy { inputs = inputs == null ? List.of() : List.copyOf(inputs); steps = steps == null ? List.of() : steps; output = output == null ? OutputSpec.inline() : output; outputIds = outputIds == null ? List.of() : List.copyOf(outputIds); + editor = editor == null ? EditorConfig.disabled() : editor; + } + + /** Without editor participation: a swept or on-demand policy. */ + public Policy( + String id, + String name, + String owner, + boolean enabled, + List inputs, + List steps, + OutputSpec output, + List outputIds, + Long teamId) { + this(id, name, owner, enabled, inputs, steps, output, outputIds, teamId, null); } /** @@ -70,6 +87,14 @@ public record Policy( return inputs.stream().map(PipelineInput::sourceId).toList(); } + /** + * The moment this policy fires in the editor ("upload" / "export"), or empty when the editor + * does not run it. Legacy blobs are lifted onto {@link EditorConfig} when they are read. + */ + public Optional editorRunOn() { + return editor.allowed() ? Optional.of(editor.runOn()) : Optional.empty(); + } + /** The distinct trigger types configured across this policy's inputs (manual inputs aside). */ public List triggerTypes() { return inputs.stream() @@ -82,17 +107,20 @@ public record Policy( /** A copy with the inline output replaced (e.g. resolved for the engine, or migrated). */ public Policy withOutput(OutputSpec resolved) { - return new Policy(id, name, owner, enabled, inputs, steps, resolved, outputIds, teamId); + return new Policy( + id, name, owner, enabled, inputs, steps, resolved, outputIds, teamId, editor); } /** A copy under a different owner (e.g. moving a seed off a placeholder name). */ public Policy withOwner(String newOwner) { - return new Policy(id, name, newOwner, enabled, inputs, steps, output, outputIds, teamId); + return new Policy( + id, name, newOwner, enabled, inputs, steps, output, outputIds, teamId, editor); } /** A copy referencing the given saved output destinations. */ public Policy withOutputIds(List newOutputIds) { - return new Policy(id, name, owner, enabled, inputs, steps, output, newOutputIds, teamId); + return new Policy( + id, name, owner, enabled, inputs, steps, output, newOutputIds, teamId, editor); } /** diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/policy/overview/PolicyOverviewService.java b/app/proprietary/src/main/java/stirling/software/proprietary/policy/overview/PolicyOverviewService.java index b2be7b668e..0d7845a209 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/policy/overview/PolicyOverviewService.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/policy/overview/PolicyOverviewService.java @@ -114,10 +114,14 @@ public class PolicyOverviewService { /** * Summarise a policy's triggers for the overview row: "manual" when no input is triggered, * otherwise the distinct trigger types across its inputs (e.g. "folder-watch, schedule"). + * + *

An editor policy has no wire input to trigger, but it is not manual either - it fires in + * the editor on every upload or export, so it reports that rather than reading as on-demand. */ private static String triggerSummary(Policy policy) { List types = policy.triggerTypes(); - return types.isEmpty() ? "manual" : String.join(", ", types); + if (!types.isEmpty()) return String.join(", ", types); + return policy.editorRunOn().map(runOn -> "editor-" + runOn).orElse("manual"); } private static String outputSummary(OutputSpec output) { diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/policy/seed/DefaultClassificationPolicySeeder.java b/app/proprietary/src/main/java/stirling/software/proprietary/policy/seed/DefaultClassificationPolicySeeder.java index 9b347366bc..7d35198633 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/policy/seed/DefaultClassificationPolicySeeder.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/policy/seed/DefaultClassificationPolicySeeder.java @@ -14,6 +14,7 @@ import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import stirling.software.proprietary.model.TeamCreatedEvent; +import stirling.software.proprietary.policy.model.EditorConfig; import stirling.software.proprietary.policy.model.OutputSpec; import stirling.software.proprietary.policy.model.PipelineStep; import stirling.software.proprietary.policy.model.Policy; @@ -98,9 +99,8 @@ public class DefaultClassificationPolicySeeder { static Policy defaultPolicy(Long teamId) { Map options = new HashMap<>(); options.put("categoryId", CATEGORY); - options.put("runOn", "upload"); options.put("mode", "new_version"); - options.put("sources", List.of("editor")); + options.put("sources", List.of()); options.put("scopeTypes", List.of()); options.put("reviewerEmail", ""); return new Policy( @@ -113,6 +113,9 @@ public class DefaultClassificationPolicySeeder { List.of(), List.of(new PipelineStep(CLASSIFY_ENDPOINT, Map.of())), new OutputSpec("inline", options), - teamId); + List.of(), + teamId, + // Classification runs in the editor on every upload. + EditorConfig.onUpload()); } } diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/policy/source/SourceOverviewService.java b/app/proprietary/src/main/java/stirling/software/proprietary/policy/source/SourceOverviewService.java index 0f9df21440..10792591d9 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/policy/source/SourceOverviewService.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/policy/source/SourceOverviewService.java @@ -107,14 +107,12 @@ public class SourceOverviewService { } /** - * Whether a policy runs from the editor. Editor membership is carried in the policy's output - * metadata ({@code output.options.sources}) - a client-side list the editor writes when a - * policy targets it - rather than as a persisted {@code sourceId}, because the editor is - * virtual and has no stored source to reference. + * Whether a policy runs from the editor. Read from the policy's first-class {@link + * stirling.software.proprietary.policy.model.EditorConfig}, never inferred from a sources list + * (the editor is not a real source). */ private static boolean runsFromEditor(Policy policy) { - Object sources = policy.output().options().get("sources"); - return sources instanceof List list && list.contains(EditorSource.ID); + return policy.editor().allowed(); } /** diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/policy/store/InProcessPolicyStore.java b/app/proprietary/src/main/java/stirling/software/proprietary/policy/store/InProcessPolicyStore.java index 70d67bba0f..08bc253863 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/policy/store/InProcessPolicyStore.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/policy/store/InProcessPolicyStore.java @@ -38,7 +38,8 @@ public class InProcessPolicyStore implements PolicyStore { policy.steps(), policy.output(), policy.outputIds(), - policy.teamId()); + policy.teamId(), + policy.editor()); policies.put(id, stored); // Existing policy keeps its position; a new one appends to the end of its team's queue. sortOrders.computeIfAbsent(id, key -> nextSortOrder(stored.teamId())); diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/policy/store/JpaPolicyStore.java b/app/proprietary/src/main/java/stirling/software/proprietary/policy/store/JpaPolicyStore.java index 6edaa76c78..f335a4d754 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/policy/store/JpaPolicyStore.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/policy/store/JpaPolicyStore.java @@ -3,6 +3,7 @@ package stirling.software.proprietary.policy.store; import java.util.List; import java.util.Objects; import java.util.Optional; +import java.util.Set; import java.util.UUID; import org.springframework.stereotype.Service; @@ -11,8 +12,10 @@ import org.springframework.transaction.annotation.Transactional; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; +import stirling.software.proprietary.policy.model.EditorConfig; import stirling.software.proprietary.policy.model.Policy; import stirling.software.proprietary.policy.model.PolicyBinding; +import stirling.software.proprietary.policy.source.EditorSource; import tools.jackson.databind.JsonNode; import tools.jackson.databind.ObjectMapper; @@ -48,7 +51,8 @@ public class JpaPolicyStore implements PolicyStore { policy.steps(), policy.output(), policy.outputIds(), - policy.teamId()); + policy.teamId(), + policy.editor()); PolicyEntity entity = new PolicyEntity(); entity.setId(id); @@ -148,7 +152,9 @@ public class JpaPolicyStore implements PolicyStore { // One unreadable row must never abort a bulk read or crash startup. private Optional toPolicy(PolicyEntity entity) { try { - JsonNode node = upgradeLegacyShape(objectMapper.readTree(entity.getPolicyJson())); + JsonNode node = + liftEditorConfig( + upgradeLegacyShape(objectMapper.readTree(entity.getPolicyJson()))); return Optional.of(objectMapper.treeToValue(node, Policy.class)); } catch (Exception e) { log.error( @@ -191,4 +197,61 @@ public class JpaPolicyStore implements PolicyStore { obj.remove("sourceIds"); return obj; } + + /** Categories whose editor moment defaulted to export before it was stored (see runOn.ts). */ + private static final Set EXPORT_BY_DEFAULT = Set.of("security"); + + /** + * Derive {@code editor} for a blob written before editor participation had its own field, from + * its {@code output.options}: allowed when {@code sources} lists {@code "editor"}, or - for a + * catalogue policy - when there is no {@code sources} list at all (an unnarrowed catalogue + * policy runs in the editor). + * + *

Runs on every read, deliberately outside {@link #upgradeLegacyShape}'s early return: a + * blob written after triggers moved onto {@code inputs} but before this field existed still + * needs lifting, and that early return would skip exactly those rows. + */ + private JsonNode liftEditorConfig(JsonNode root) { + if (!(root instanceof ObjectNode obj) || obj.hasNonNull("editor")) { + return root; + } + JsonNode options = obj.path("output").path("options"); + String categoryId = text(options, "categoryId"); + JsonNode sources = options.get("sources"); + boolean listed = sources != null && sources.isArray() && !sources.isEmpty(); + boolean allowed; + if (listed) { + // An explicit scope list decides: only the editor's own id puts it on the editor. + allowed = false; + for (JsonNode source : sources) { + if (source.isValueNode() && EditorSource.ID.equals(source.asString())) { + allowed = true; + break; + } + } + } else { + // No list: a catalogue policy ran in the editor by default, but a builder pipeline + // (no category) could not reach the editor at all, so silence is not consent there. + allowed = !categoryId.isBlank(); + } + ObjectNode editor = objectMapper.createObjectNode(); + editor.put("allowed", allowed); + editor.put("runOn", legacyRunOn(options, categoryId)); + obj.set("editor", editor); + return obj; + } + + /** The stored moment, or the category default the client applied when none was stored. */ + private static String legacyRunOn(JsonNode options, String categoryId) { + String stored = text(options, "runOn"); + if (EditorConfig.EXPORT.equals(stored) || EditorConfig.UPLOAD.equals(stored)) { + return stored; + } + return EXPORT_BY_DEFAULT.contains(categoryId) ? EditorConfig.EXPORT : EditorConfig.UPLOAD; + } + + private static String text(JsonNode parent, String field) { + JsonNode node = parent.path(field); + return node.isValueNode() ? node.asString() : ""; + } } diff --git a/app/proprietary/src/test/java/stirling/software/proprietary/policy/overview/PolicyOverviewServiceTest.java b/app/proprietary/src/test/java/stirling/software/proprietary/policy/overview/PolicyOverviewServiceTest.java index 373b596136..4d0830f9c5 100644 --- a/app/proprietary/src/test/java/stirling/software/proprietary/policy/overview/PolicyOverviewServiceTest.java +++ b/app/proprietary/src/test/java/stirling/software/proprietary/policy/overview/PolicyOverviewServiceTest.java @@ -15,6 +15,7 @@ import stirling.software.common.model.ApplicationProperties; import stirling.software.common.service.UserServiceInterface; import stirling.software.proprietary.policy.config.PolicyAccessGuard; import stirling.software.proprietary.policy.config.PolicyManagementAuthority; +import stirling.software.proprietary.policy.model.EditorConfig; import stirling.software.proprietary.policy.model.OutputSpec; import stirling.software.proprietary.policy.model.PipelineInput; import stirling.software.proprietary.policy.model.PipelineStep; @@ -223,6 +224,44 @@ class PolicyOverviewServiceTest { teamId)); } + @Test + void editorPolicyReportsItsRunMomentRatherThanReadingAsManual() { + policyStore.save( + new Policy( + null, + "Editor flatten", + "owner", + true, + List.of(), + List.of(new PipelineStep("/api/v1/misc/flatten", Map.of())), + OutputSpec.inline(), + List.of(), + 1L, + EditorConfig.onUpload())); + + PolicyView view = find(service.overview(), "Editor flatten"); + + assertEquals("editor-upload", view.trigger()); + } + + @Test + void sweptPolicyWithNoTriggeredInputIsStillManual() { + policyStore.save( + new Policy( + null, + "Swept compress", + "owner", + true, + List.of(), + List.of(new PipelineStep("/api/v1/misc/compress-pdf", Map.of())), + OutputSpec.inline(), + 1L)); + + PolicyView view = find(service.overview(), "Swept compress"); + + assertEquals("manual", view.trigger()); + } + private static PolicyView find(PoliciesOverviewResponse response, String name) { return response.pipelines().stream() .filter(view -> view.name().equals(name)) diff --git a/app/proprietary/src/test/java/stirling/software/proprietary/policy/seed/DefaultClassificationPolicySeederTest.java b/app/proprietary/src/test/java/stirling/software/proprietary/policy/seed/DefaultClassificationPolicySeederTest.java index f6e82bd011..fb4fa6d419 100644 --- a/app/proprietary/src/test/java/stirling/software/proprietary/policy/seed/DefaultClassificationPolicySeederTest.java +++ b/app/proprietary/src/test/java/stirling/software/proprietary/policy/seed/DefaultClassificationPolicySeederTest.java @@ -64,14 +64,30 @@ class DefaultClassificationPolicySeederTest { assertThat(policy.teamId()).isEqualTo(7L); assertThat(policy.output().type()).isEqualTo("inline"); assertThat(policy.output().options().get("categoryId")).isEqualTo("classification"); - assertThat(policy.output().options().get("runOn")).isEqualTo("upload"); assertThat(policy.output().options().get("mode")).isEqualTo("new_version"); - assertThat(policy.output().options().get("sources")).isEqualTo(List.of("editor")); + // Editor participation is the policy's own flag, not a marker in the output options. + assertThat(policy.editor().allowed()).isTrue(); + assertThat(policy.editor().runOn()).isEqualTo("upload"); assertThat(policy.steps()).hasSize(1); assertThat(policy.steps().get(0).operation()) .isEqualTo("/api/v1/ai/tools/classify-and-label"); } + @Test + void marksEditorParticipationOnEditorConfigAndSeedsNoSources() { + when(policyStore.findByTeam(7L)).thenReturn(List.of()); + + seeder().onTeamCreated(new TeamCreatedEvent(7L, "Acme")); + + ArgumentCaptor saved = ArgumentCaptor.forClass(Policy.class); + verify(policyStore).save(saved.capture()); + Policy policy = saved.getValue(); + // Editor participation is on EditorConfig, not the sources list; the seed carries no + // sources. + assertThat(policy.editor().allowed()).isTrue(); + assertThat(policy.output().options().get("sources")).isEqualTo(List.of()); + } + @Test void doesNotSeedWhenAClassificationPolicyAlreadyExists() { when(policyStore.findByTeam(7L)).thenReturn(List.of(classificationPolicy(7L))); diff --git a/app/proprietary/src/test/java/stirling/software/proprietary/policy/source/SourceOverviewServiceTest.java b/app/proprietary/src/test/java/stirling/software/proprietary/policy/source/SourceOverviewServiceTest.java index a8c295acc8..66d75f8be0 100644 --- a/app/proprietary/src/test/java/stirling/software/proprietary/policy/source/SourceOverviewServiceTest.java +++ b/app/proprietary/src/test/java/stirling/software/proprietary/policy/source/SourceOverviewServiceTest.java @@ -15,6 +15,7 @@ import stirling.software.common.model.ApplicationProperties; import stirling.software.common.service.UserServiceInterface; import stirling.software.proprietary.policy.config.PolicyAccessGuard; import stirling.software.proprietary.policy.config.PolicyManagementAuthority; +import stirling.software.proprietary.policy.model.EditorConfig; import stirling.software.proprietary.policy.model.OutputSpec; import stirling.software.proprietary.policy.model.PipelineInput; import stirling.software.proprietary.policy.model.PipelineStep; @@ -222,9 +223,7 @@ class SourceOverviewServiceTest { OutputSpec.inline())); } - /** - * A policy that targets the editor: membership rides in its output metadata, not a sourceId. - */ + /** A policy that targets the editor: membership on its {@link EditorConfig}, not a sourceId. */ private void editorPolicy(String name) { policyStore.save( new Policy( @@ -234,7 +233,10 @@ class SourceOverviewServiceTest { true, List.of(), List.of(new PipelineStep("/api/v1/misc/compress-pdf", Map.of())), - new OutputSpec("inline", Map.of("sources", List.of("editor"))))); + OutputSpec.inline(), + List.of(), + null, + EditorConfig.onUpload())); } private void teamPolicy(String name, Long teamId, String... sourceIds) { diff --git a/app/proprietary/src/test/java/stirling/software/proprietary/policy/store/JpaPolicyStoreTest.java b/app/proprietary/src/test/java/stirling/software/proprietary/policy/store/JpaPolicyStoreTest.java index 2a1d2b4f11..ae95b3a3ce 100644 --- a/app/proprietary/src/test/java/stirling/software/proprietary/policy/store/JpaPolicyStoreTest.java +++ b/app/proprietary/src/test/java/stirling/software/proprietary/policy/store/JpaPolicyStoreTest.java @@ -18,6 +18,7 @@ import org.mockito.ArgumentCaptor; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; +import stirling.software.proprietary.policy.model.EditorConfig; import stirling.software.proprietary.policy.model.OutputSpec; import stirling.software.proprietary.policy.model.PipelineInput; import stirling.software.proprietary.policy.model.PipelineStep; @@ -113,6 +114,129 @@ class JpaPolicyStoreTest { upgraded.inputs()); } + /** + * The regression this guards: before the editor lift, a blob written by the pre-{@code editor} + * seeder deserialized straight onto {@link EditorConfig#disabled()}, silently taking every + * upgraded install's Classification policy off the editor. + * + *

The {@code inputs} variant is the important one - {@link + * JpaPolicyStore#upgradeLegacyShape} returns early on it, so a lift living inside that method + * would miss exactly the rows written between the trigger migration and this field. + */ + @Test + void getLiftsALegacyEditorSourceOntoEditorConfigWhenInputsArePresent() { + Policy lifted = readLegacy(legacyJson("\"inputs\":[],", "\"sources\":[\"editor\"],")); + + assertEquals(EditorConfig.onUpload(), lifted.editor()); + assertEquals(Optional.of("upload"), lifted.editorRunOn()); + } + + @Test + void getLiftsALegacyEditorSourceOnThePreInputsShapeToo() { + // Oldest shape: policy-level trigger + sourceIds, so both migrations have to compose. + Policy lifted = + readLegacy( + legacyJson( + "\"trigger\":{\"type\":\"schedule\",\"options\":{}}," + + "\"sourceIds\":[\"s1\"],", + "\"sources\":[\"editor\"],")); + + assertEquals(EditorConfig.onUpload(), lifted.editor()); + assertEquals( + List.of(new PipelineInput("s1", new TriggerConfig("schedule", Map.of()))), + lifted.inputs()); + } + + @Test + void getTreatsAnUnnarrowedCataloguePolicyAsEditorRun() { + // Empty and absent both meant "nobody narrowed it", which the editor read as its own. + assertTrue(readLegacy(legacyJson("\"inputs\":[],", "\"sources\":[],")).editor().allowed()); + assertTrue(readLegacy(legacyJson("\"inputs\":[],", "")).editor().allowed()); + } + + @Test + void getLeavesACataloguePolicyScopedElsewhereOffTheEditor() { + Policy lifted = readLegacy(legacyJson("\"inputs\":[],", "\"sources\":[\"sharepoint\"],")); + + assertFalse(lifted.editor().allowed()); + assertEquals(Optional.empty(), lifted.editorRunOn()); + } + + @Test + void getLeavesASourcelessBuilderPipelineOffTheEditor() { + // No categoryId: a pipeline built on the Pipelines page, which never reached the editor. + String json = + "{\"id\":\"p1\",\"name\":\"legacy\",\"enabled\":true,\"inputs\":[]," + + "\"steps\":[],\"output\":{\"type\":\"inline\",\"options\":{}}}"; + + assertFalse(readLegacy(json).editor().allowed()); + } + + @Test + void getKeepsTheCategoryDefaultMomentWhenNoRunOnWasStored() { + // Security enforced on export before runOn was persisted (frontend runOn.ts + // DEFAULT_RUN_ON). + String json = + "{\"id\":\"p1\",\"name\":\"legacy\",\"enabled\":true,\"inputs\":[]," + + "\"steps\":[],\"output\":{\"type\":\"inline\",\"options\":{" + + "\"categoryId\":\"security\",\"sources\":[\"editor\"]}}}"; + + assertEquals(EditorConfig.onExport(), readLegacy(json).editor()); + } + + @Test + void getNeverOverridesAnExplicitlyStoredEditorBlock() { + // A deliberate opt-out survives, so the lift stays safe to leave in permanently. + String json = + "{\"id\":\"p1\",\"name\":\"legacy\",\"enabled\":true,\"inputs\":[]," + + "\"steps\":[],\"editor\":{\"allowed\":false,\"runOn\":\"upload\"}," + + "\"output\":{\"type\":\"inline\",\"options\":{" + + "\"categoryId\":\"classification\",\"sources\":[\"editor\"]}}}"; + + assertFalse(readLegacy(json).editor().allowed()); + } + + /** + * Pins the wire shape the stubbed Playwright spec hardcodes: the derived block is additive, so + * a real response carries it alongside the untouched legacy options bag. + */ + @Test + void getLeavesTheLegacyOptionsBagIntactSoTheResponseCarriesBoth() { + Policy lifted = readLegacy(legacyJson("\"inputs\":[],", "\"sources\":[\"editor\"],")); + + assertEquals(List.of("editor"), lifted.output().options().get("sources")); + String wire = objectMapper.writeValueAsString(lifted); + assertTrue( + wire.contains("\"editor\":{\"allowed\":true,\"runOn\":\"upload\"}"), + "expected the derived editor block on the wire, got: " + wire); + } + + /** + * The blob main's DefaultClassificationPolicySeeder wrote, with the shape bits parameterised. + */ + private static String legacyJson(String shapeFields, String sourcesField) { + return "{\"id\":\"p1\",\"name\":\"Classification Policy\",\"owner\":\"system\"," + + "\"enabled\":true," + + shapeFields + + "\"steps\":[{\"operation\":\"/api/v1/ai/tools/classify-and-label\"," + + "\"parameters\":{}}]," + + "\"output\":{\"type\":\"inline\",\"options\":{" + + "\"categoryId\":\"classification\",\"runOn\":\"upload\"," + + "\"mode\":\"new_version\"," + + sourcesField + + "\"scopeTypes\":[],\"reviewerEmail\":\"\"}},\"teamId\":1}"; + } + + private Policy readLegacy(String policyJson) { + PolicyEntity entity = new PolicyEntity(); + entity.setId("p1"); + entity.setName("legacy"); + entity.setEnabled(true); + entity.setPolicyJson(policyJson); + when(repository.findById("p1")).thenReturn(Optional.of(entity)); + return store.get("p1").orElseThrow(); + } + @Test void saveDenormalizesTeamIdForScopedQueries() { store.save( diff --git a/frontend/editor/public/locales/en-US/translation.toml b/frontend/editor/public/locales/en-US/translation.toml index df996b23c0..345cf07ab2 100644 --- a/frontend/editor/public/locales/en-US/translation.toml +++ b/frontend/editor/public/locales/en-US/translation.toml @@ -8220,6 +8220,9 @@ chooseDestination = "Choose a destination" chooseOperation = "Choose what this step does" chooseSource = "Choose a source" discard = "Discard changes" +editorDestination = "Editor" +editorDestinationDetail = "Replaces the file you ran it on" +editorDestinationHelp = "This pipeline runs on the files in your workspace, and its results replace the file it ran on. There is nowhere else to send them." inputs = "Input" inputSource = "Input source" inputTrigger = "Trigger" @@ -8231,6 +8234,10 @@ needsSource = "No source chosen" noToolMatches = "No tools match your search." pause = "Pause" rename = "Rename pipeline" +runOn = "Runs on" +runOnExport = "Every export" +runOnTooltip = "Choose when this pipeline runs on your files: when you add them, or when you export them." +runOnUpload = "Every upload" searchTools = "Search tools" sendToSystem = "Send to another system" stepsIncompatible = "These steps can't run on what their prior step produces: {{tools}}." @@ -8371,6 +8378,8 @@ steps = "Steps" trigger = "Trigger" [portal.pipelines.trigger] +editor-export = "Every export" +editor-upload = "Every upload" folder-watch = "Folder watch" manual = "Manual" schedule = "Scheduled" diff --git a/frontend/editor/src/core/components/fileManager/CompactFileDetails.tsx b/frontend/editor/src/core/components/fileManager/CompactFileDetails.tsx index 988ecf789c..27f55ce060 100644 --- a/frontend/editor/src/core/components/fileManager/CompactFileDetails.tsx +++ b/frontend/editor/src/core/components/fileManager/CompactFileDetails.tsx @@ -7,6 +7,7 @@ import ChevronLeftIcon from "@mui/icons-material/ChevronLeft"; import ChevronRightIcon from "@mui/icons-material/ChevronRight"; import { useTranslation } from "react-i18next"; import { getFileSize } from "@app/utils/fileUtils"; +import { toolOperationLabel } from "@app/utils/toolOperationLabel"; import { StirlingFileStub } from "@app/types/fileContext"; import { PrivateContent } from "@app/components/shared/PrivateContent"; @@ -115,7 +116,7 @@ const CompactFileDetails: React.FC = ({ {currentFile?.toolHistory && currentFile.toolHistory.length > 0 && ( {currentFile.toolHistory - .map((tool) => t(`home.${tool.toolId}.title`, tool.toolId)) + .map((tool) => toolOperationLabel(tool, t)) .join(" → ")} )} diff --git a/frontend/editor/src/core/components/filesPage/VersionTimeline.tsx b/frontend/editor/src/core/components/filesPage/VersionTimeline.tsx index f484f936ef..77d8808c17 100644 --- a/frontend/editor/src/core/components/filesPage/VersionTimeline.tsx +++ b/frontend/editor/src/core/components/filesPage/VersionTimeline.tsx @@ -10,7 +10,7 @@ import HistoryIcon from "@mui/icons-material/History"; import MoreVertIcon from "@mui/icons-material/MoreVert"; import { FileId, ToolOperation } from "@app/types/file"; -import { ToolId } from "@app/types/toolId"; +import { toolOperationLabel } from "@app/utils/toolOperationLabel"; import { StirlingFileStub } from "@app/types/fileContext"; import { formatFileSize, getFileDate } from "@app/utils/fileUtils"; import { downloadFileFromStorage } from "@app/utils/downloadUtils"; @@ -64,10 +64,10 @@ function deltaToolFor( return curr[priorLen] ?? null; } -/** Translated tool name via `home.{toolId}.title`. */ -function ToolLabel({ toolId }: { toolId: ToolId }) { +/** The operation's own label when it has one, else its translated tool name. */ +function ToolLabel({ operation }: { operation: ToolOperation }) { const { t } = useTranslation(); - return {t(`home.${toolId}.title`, toolId)}; + return {toolOperationLabel(operation, t)}; } export interface VersionTimelineProps { @@ -242,7 +242,7 @@ export function VersionTimeline({ style={{ color: "var(--c-text)" }} > {delta ? ( - + ) : ( t("filesPage.versionOrigin", "Original upload") )} diff --git a/frontend/editor/src/core/components/shared/ToolChain.tsx b/frontend/editor/src/core/components/shared/ToolChain.tsx index 249e7802cc..7974614759 100644 --- a/frontend/editor/src/core/components/shared/ToolChain.tsx +++ b/frontend/editor/src/core/components/shared/ToolChain.tsx @@ -6,8 +6,8 @@ import React from "react"; import { Text, Tooltip, Badge, Group } from "@mantine/core"; import { ToolOperation } from "@app/types/file"; +import { toolOperationLabel } from "@app/utils/toolOperationLabel"; import { useTranslation } from "react-i18next"; -import { ToolId } from "@app/types/toolId"; interface ToolChainProps { toolChain: ToolOperation[]; @@ -29,11 +29,7 @@ const ToolChain: React.FC = ({ const { t } = useTranslation(); if (!toolChain || toolChain.length === 0) return null; - const toolIds = toolChain.map((tool) => tool.toolId); - - const getToolName = (toolId: ToolId) => { - return t(`home.${toolId}.title`, toolId); - }; + const getToolName = (tool: ToolOperation) => toolOperationLabel(tool, t); // Create full tool chain for tooltip const fullChainDisplay = @@ -42,7 +38,7 @@ const ToolChain: React.FC = ({ {toolChain.map((tool, index) => ( - {getToolName(tool.toolId)} + {getToolName(tool)} {index < toolChain.length - 1 && ( @@ -53,18 +49,21 @@ const ToolChain: React.FC = ({ ))} ) : ( - {toolIds.map(getToolName).join(" → ")} + {toolChain.map(getToolName).join(" → ")} ); // Create truncated display based on available space const getTruncatedDisplay = () => { - if (toolIds.length <= 2) { + if (toolChain.length <= 2) { // Show all tools if 2 or fewer - return { text: toolIds.map(getToolName).join(" → "), isTruncated: false }; + return { + text: toolChain.map(getToolName).join(" → "), + isTruncated: false, + }; } else { // Show first tool ... last tool for longer chains return { - text: `${getToolName(toolIds[0])} → +${toolIds.length - 2} → ${getToolName(toolIds[toolIds.length - 1])}`, + text: `${getToolName(toolChain[0])} → +${toolChain.length - 2} → ${getToolName(toolChain[toolChain.length - 1])}`, isTruncated: true, }; } @@ -75,10 +74,10 @@ const ToolChain: React.FC = ({ // Compact style for very small spaces if (displayStyle === "compact") { const compactText = - toolIds.length === 1 - ? getToolName(toolIds[0]) - : `${toolIds.length} tools`; - const isCompactTruncated = toolIds.length > 1; + toolChain.length === 1 + ? getToolName(toolChain[0]) + : `${toolChain.length} tools`; + const isCompactTruncated = toolChain.length > 1; const compactElement = ( = ({ {toolChain.slice(0, 3).map((tool, index) => ( - {getToolName(tool.toolId)} + {getToolName(tool)} {index < Math.min(toolChain.length - 1, 2) && ( @@ -131,7 +130,7 @@ const ToolChain: React.FC = ({ ... - {getToolName(toolChain[toolChain.length - 1].toolId)} + {getToolName(toolChain[toolChain.length - 1])} )} @@ -140,7 +139,7 @@ const ToolChain: React.FC = ({ ); return isBadgesTruncated ? ( - + {badgesElement} ) : ( diff --git a/frontend/editor/src/core/contexts/file/FileReducer.test.ts b/frontend/editor/src/core/contexts/file/FileReducer.test.ts index cdba68b013..92f82f3ed0 100644 --- a/frontend/editor/src/core/contexts/file/FileReducer.test.ts +++ b/frontend/editor/src/core/contexts/file/FileReducer.test.ts @@ -192,6 +192,39 @@ describe("fileContextReducer — silent CONSUME_FILES (background enforcement)", ]); }); + it("carries a no-label [] verdict forward (classified, not unclassified)", () => { + // "a" was classified and found nothing ([]) - distinct from null (never classified). The output + // must inherit [] so the local pass treats it as already-classified and never re-classifies (or + // re-bills) it. + const start = stateWith([stub("a", { classificationLabels: [] })]); + const next = fileContextReducer(start, { + type: "CONSUME_FILES", + payload: { + inputFileIds: ["a" as FileId], + outputStirlingFileStubs: [stub("b")], + }, + }); + expect(next.files.byId["b" as FileId].classificationLabels).toEqual([]); + }); + + it("prefers a real label over a merge input's no-label [] verdict", () => { + // Merge of a labelled file and a no-label one: the output should keep the real label. + const start = stateWith([ + stub("a", { classificationLabels: [] }), + stub("b", { classificationLabels: ["Invoice"] }), + ]); + const next = fileContextReducer(start, { + type: "CONSUME_FILES", + payload: { + inputFileIds: ["a" as FileId, "b" as FileId], + outputStirlingFileStubs: [stub("c")], + }, + }); + expect(next.files.byId["c" as FileId].classificationLabels).toEqual([ + "Invoice", + ]); + }); + it("an output's own classificationLabels win over the input's", () => { // A re-classify produces an output that already carries (fresher) labels. const start = stateWith([stub("a", { classificationLabels: ["Invoice"] })]); @@ -211,7 +244,7 @@ describe("fileContextReducer — silent CONSUME_FILES (background enforcement)", it("carries classificationConfidence forward with the labels", () => { // The confidence is part of the verdict: without it the escalation decision - // (shouldDispatchToAi) dies at the version boundary and a chained + // (localVerdictNeedsEscalation) dies at the version boundary and a chained // classification never runs. const start = stateWith([ stub("a", { diff --git a/frontend/editor/src/core/contexts/file/FileReducer.ts b/frontend/editor/src/core/contexts/file/FileReducer.ts index 1ed23b11d6..ba69492b23 100644 --- a/frontend/editor/src/core/contexts/file/FileReducer.ts +++ b/frontend/editor/src/core/contexts/file/FileReducer.ts @@ -389,16 +389,16 @@ export function fileContextReducer( // Carry the document's classification verdict forward across the edit: any // tool that versions/derives a classified file keeps it in its label // groups instead of dropping to "Other" and waiting on a PDF re-read. - // Inherited from the first input that has labels, together with that - // verdict's confidence - the escalation decision (shouldDispatchToAi) is - // about the document, not about which step produced the current bytes, so - // it must survive the version boundary. An output that already carries its - // own verdict (e.g. a fresh classify result) keeps it. - const verdictDonor = inputFileIds - .map((id) => state.files.byId[id]) - .find( + // Inherited together with that verdict's confidence - the escalation + // decision (localVerdictNeedsEscalation) is about the document, not about + // which step produced the current bytes, so it must survive the version + // boundary. An output that already carries its own verdict (e.g. a fresh + // classify result) keeps it. + const inputStubs = inputFileIds.map((id) => state.files.byId[id]); + const verdictDonor = + inputStubs.find( (s) => s?.classificationLabels && s.classificationLabels.length > 0, - ); + ) ?? inputStubs.find((s) => s?.classificationLabels !== undefined); // Mark every consume output as tool-produced (the single chokepoint for // both versioned edits and independent artifacts like convert/split/merge) @@ -409,7 +409,7 @@ export function fileContextReducer( ...stub, derivedFromTool: true, sourceFileIds, - ...(stub.classificationLabels == null && verdictDonor + ...(stub.classificationLabels === undefined && verdictDonor ? { classificationLabels: verdictDonor.classificationLabels, classificationConfidence: diff --git a/frontend/editor/src/core/services/fileStubHelpers.ts b/frontend/editor/src/core/services/fileStubHelpers.ts index 60489c8c94..836d000af7 100644 --- a/frontend/editor/src/core/services/fileStubHelpers.ts +++ b/frontend/editor/src/core/services/fileStubHelpers.ts @@ -14,6 +14,8 @@ export async function createStirlingFilesAndStubs( files: File[], parentStub: StirlingFileStub, toolId: ToolId, + /** Shown instead of the tool's name in version history (a policy passes its pipeline name). */ + label?: string, ): Promise<{ stirlingFiles: StirlingFile[]; stubs: StirlingFileStub[] }> { const stirlingFiles: StirlingFile[] = []; const stubs: StirlingFileStub[] = []; @@ -22,7 +24,7 @@ export async function createStirlingFilesAndStubs( const processedFileMetadata = await generateProcessedFileMetadata(file); const childStub = createChildStub( parentStub, - { toolId, timestamp: Date.now() }, + { toolId, timestamp: Date.now(), ...(label ? { label } : {}) }, file, processedFileMetadata?.thumbnailUrl, processedFileMetadata, diff --git a/frontend/editor/src/core/tests/stubbed/classification-heuristic-upload.spec.ts b/frontend/editor/src/core/tests/stubbed/classification-heuristic-upload.spec.ts index 856ddd58f5..7d42434385 100644 --- a/frontend/editor/src/core/tests/stubbed/classification-heuristic-upload.spec.ts +++ b/frontend/editor/src/core/tests/stubbed/classification-heuristic-upload.spec.ts @@ -12,14 +12,20 @@ const FIXTURES = path.join( "../test-fixtures/classification/unlabelled", ); -/** The stored policy DefaultClassificationPolicySeeder writes for a new team. */ +/** + * What GET /api/v1/policies returns for the row an older + * DefaultClassificationPolicySeeder wrote - i.e. one stored before editor + * participation had its own field. `JpaPolicyStore.liftEditorConfig` derives the + * `editor` block from the legacy `output.options` on read; the lift is additive, + * so a real response carries both. Migration of the stored shape itself is + * covered by JpaPolicyStoreTest, which exercises the Java the stub stands in for. + */ const SEEDED_POLICY = { id: "seeded-classification", name: "Classification Policy", owner: "system", enabled: true, - trigger: null, - sourceIds: [], + inputs: [], steps: [{ operation: "/api/v1/ai/tools/classify-and-label", parameters: {} }], output: { type: "inline", @@ -32,7 +38,9 @@ const SEEDED_POLICY = { reviewerEmail: "", }, }, + outputIds: [], teamId: 1, + editor: { allowed: true, runOn: "upload" }, }; test("a 10-file upload wave classifies every file into its group", async ({ diff --git a/frontend/editor/src/core/tests/stubbed/editor-pipeline-auto-run.spec.ts b/frontend/editor/src/core/tests/stubbed/editor-pipeline-auto-run.spec.ts new file mode 100644 index 0000000000..a71bc98f33 --- /dev/null +++ b/frontend/editor/src/core/tests/stubbed/editor-pipeline-auto-run.spec.ts @@ -0,0 +1,85 @@ +import path from "path"; +import { test, expect } from "@app/tests/helpers/stub-test-base"; +import { uploadFiles } from "@app/tests/helpers/ui-helpers"; + +// A pipeline reaches the editor auto-run through its own editor flag; a swept one must not. + +test.use({ autoGoto: false }); + +const SAMPLE = path.join( + import.meta.dirname, + "../test-fixtures/classification/unlabelled/invoice_acme.pdf", +); + +/** A builder-made pipeline: no categoryId, one harmless step. */ +function builderPipeline(editor: { allowed: boolean; runOn: string }) { + return { + id: "builder-pipeline-1", + name: "Flatten everything", + owner: "system", + enabled: true, + trigger: null, + sourceIds: [], + steps: [{ operation: "/api/v1/misc/flatten", parameters: {} }], + output: { type: "inline", options: { mode: "new_version" } }, + editor, + teamId: 1, + }; +} + +/** Install the policy list + capture every stored-policy run dispatch. */ +async function armed(page: import("@playwright/test").Page, policy: unknown) { + const dispatched: string[] = []; + await page.route("**/api/v1/policies", (route) => + route.fulfill({ json: [policy] }), + ); + await page.route("**/api/v1/policies/*/run", (route) => { + dispatched.push(new URL(route.request().url()).pathname); + return route.fulfill({ json: { jobId: "job-1" } }); + }); + return dispatched; +} + +test("an editor pipeline set to run on upload dispatches when a file is added", async ({ + page, +}) => { + const dispatched = await armed( + page, + builderPipeline({ allowed: true, runOn: "upload" }), + ); + + await page.goto("/editor", { waitUntil: "domcontentloaded" }); + await uploadFiles(page, SAMPLE); + + await expect + .poll(() => dispatched, { timeout: 15_000 }) + .toContain("/api/v1/policies/builder-pipeline-1/run"); +}); + +test("a swept pipeline never runs on editor upload", async ({ page }) => { + const dispatched = await armed( + page, + builderPipeline({ allowed: false, runOn: "upload" }), + ); + + await page.goto("/editor", { waitUntil: "domcontentloaded" }); + await uploadFiles(page, SAMPLE); + + await page.waitForTimeout(5_000); + expect(dispatched).toEqual([]); +}); + +test("an editor pipeline set to run on export does not fire on upload", async ({ + page, +}) => { + const dispatched = await armed( + page, + builderPipeline({ allowed: true, runOn: "export" }), + ); + + await page.goto("/editor", { waitUntil: "domcontentloaded" }); + await uploadFiles(page, SAMPLE); + + await page.waitForTimeout(5_000); + expect(dispatched).toEqual([]); +}); diff --git a/frontend/editor/src/core/types/file.ts b/frontend/editor/src/core/types/file.ts index 98a0094f43..c6ec1898cb 100644 --- a/frontend/editor/src/core/types/file.ts +++ b/frontend/editor/src/core/types/file.ts @@ -16,6 +16,9 @@ export type FileId = string & { readonly [tag]: "FileId" }; export interface ToolOperation { toolId: ToolId; timestamp: number; + /** Overrides the tool's own name in history. Set by a policy run to its pipeline's name, since + * every policy records the same "automate" toolId. */ + label?: string; } /** diff --git a/frontend/editor/src/core/utils/toolOperationLabel.test.ts b/frontend/editor/src/core/utils/toolOperationLabel.test.ts new file mode 100644 index 0000000000..9b21f6e65f --- /dev/null +++ b/frontend/editor/src/core/utils/toolOperationLabel.test.ts @@ -0,0 +1,29 @@ +import { describe, it, expect } from "vitest"; +import type { TFunction } from "i18next"; +import { toolOperationLabel } from "@app/utils/toolOperationLabel"; +import type { ToolOperation } from "@app/types/file"; + +// Stands in for i18next: echoes the key so the assertions show which lookup ran. +const t = ((key: string, fallback?: string) => + key === "home.automate.title" ? "Automate" : (fallback ?? key)) as TFunction; + +const op = (over: Partial): ToolOperation => + ({ toolId: "automate", timestamp: 0, ...over }) as ToolOperation; + +describe("toolOperationLabel", () => { + it("prefers the operation's own label", () => { + expect(toolOperationLabel(op({ label: "add-page-numbers" }), t)).toBe( + "add-page-numbers", + ); + }); + + // Every policy records the same "automate" toolId, so without a label each automated version + // reads identically no matter which pipeline produced it. + it("falls back to the tool's name when unlabelled", () => { + expect(toolOperationLabel(op({}), t)).toBe("Automate"); + }); + + it("keeps the fallback for an empty label rather than rendering a blank", () => { + expect(toolOperationLabel(op({ label: "" }), t)).toBe("Automate"); + }); +}); diff --git a/frontend/editor/src/core/utils/toolOperationLabel.ts b/frontend/editor/src/core/utils/toolOperationLabel.ts new file mode 100644 index 0000000000..1f29d02390 --- /dev/null +++ b/frontend/editor/src/core/utils/toolOperationLabel.ts @@ -0,0 +1,17 @@ +import type { TFunction } from "i18next"; +import type { ToolOperation } from "@app/types/file"; + +/** + * What produced a version, for the history surfaces. A policy run carries its own label (the + * pipeline's name) because every policy records the same "automate" toolId, which would otherwise + * render every automated version identically. + */ +export function toolOperationLabel( + operation: ToolOperation, + t: TFunction, +): string { + // Truthiness, not nullish: a blank label would otherwise render as an empty history entry. + return ( + operation.label || t(`home.${operation.toolId}.title`, operation.toolId) + ); +} diff --git a/frontend/editor/src/portal/api/pipelines.ts b/frontend/editor/src/portal/api/pipelines.ts index e441fcdac8..2be269c1ac 100644 --- a/frontend/editor/src/portal/api/pipelines.ts +++ b/frontend/editor/src/portal/api/pipelines.ts @@ -70,6 +70,8 @@ export interface Policy { * output} is used. */ outputIds: string[]; + /** Whether the editor runs this policy per file, and on which moment. */ + editor?: { allowed: boolean; runOn: "upload" | "export" }; teamId?: number | null; } diff --git a/frontend/editor/src/portal/api/policies.ts b/frontend/editor/src/portal/api/policies.ts index da545b98c4..6963ccc1b1 100644 --- a/frontend/editor/src/portal/api/policies.ts +++ b/frontend/editor/src/portal/api/policies.ts @@ -76,6 +76,8 @@ export interface PolicyState { configured: boolean; status: PolicyStatus; sources: string[]; + /** Whether the editor runs this policy per file; stored, not derived from `sources`. */ + runsOnEditor?: boolean; scopeTypes: string[]; reviewerEmail: string; fieldValues: Record; @@ -92,6 +94,7 @@ export interface PolicyState { export interface PolicySetupResult { fieldValues: Record; sources: string[]; + runsOnEditor: boolean; scopeTypes: string[]; reviewerEmail: string; outputMode: "new_file" | "new_version"; @@ -433,6 +436,7 @@ function decoratePolicy( configured: true, status, sources: decoded.sources, + runsOnEditor: decoded.runsOnEditor, scopeTypes: decoded.scopeTypes, reviewerEmail: decoded.reviewerEmail, fieldValues: decoded.fieldValues, @@ -595,6 +599,7 @@ export function buildWireFromSetup( enabled, categoryId: entry.category.id, sources: result.sources, + runsOnEditor: result.runsOnEditor, scopeTypes: result.scopeTypes, reviewerEmail: result.reviewerEmail, fieldValues: result.fieldValues, @@ -625,6 +630,8 @@ export function buildWireFromState( enabled, categoryId: entry.category.id, sources: s.sources, + // Carry the stored value through: pause/resume must not re-derive it. + runsOnEditor: s.runsOnEditor === true, scopeTypes: s.scopeTypes, reviewerEmail: s.reviewerEmail, fieldValues: s.fieldValues, diff --git a/frontend/editor/src/portal/components/pipelines/PipelineInputTrigger.tsx b/frontend/editor/src/portal/components/pipelines/PipelineInputTrigger.tsx new file mode 100644 index 0000000000..eb5af9cd21 --- /dev/null +++ b/frontend/editor/src/portal/components/pipelines/PipelineInputTrigger.tsx @@ -0,0 +1,140 @@ +// Swept sources are scheduled or triggered server-side; the editor runs client-side as each file +// passes through, so the two get different controls. + +import { useTranslation } from "react-i18next"; +import { Tooltip } from "@mantine/core"; +import InfoOutlinedIcon from "@mui/icons-material/InfoOutlined"; +import { FormField, Input, Select } from "@app/ui"; + +export type ScheduleUnit = "MINUTES" | "HOURS" | "DAYS"; +export type EditorRunOn = "upload" | "export"; + +const SCHEDULE_UNITS: ScheduleUnit[] = ["MINUTES", "HOURS", "DAYS"]; + +/** Empty trigger type = manual-only (no automatic trigger). */ +export const MANUAL = ""; +/** Sentinel for manual: Mantine's Select reads "" as no selection. Maps to {@link MANUAL}. */ +export const MANUAL_OPTION = "manual"; + +/** One input row in the builder: a source paired with its own trigger config. */ +export interface WorkingInput { + sourceId: string; + triggerType: string; + scheduleCount: string; + scheduleUnit: ScheduleUnit; +} + +export interface PipelineInputTriggerProps { + input: WorkingInput; + onInputChange: (patch: Partial) => void; + /** Trigger types offered for this row's source (manual first). */ + triggerOptions: { value: string; label: string }[]; + /** The chosen source is the editor, so the pipeline runs in the browser. */ + isEditorInput: boolean; + runOn: EditorRunOn; + onRunOnChange: (runOn: EditorRunOn) => void; +} + +export function PipelineInputTrigger({ + input, + onInputChange, + triggerOptions, + isEditorInput, + runOn, + onRunOnChange, +}: PipelineInputTriggerProps) { + const { t } = useTranslation(); + + if (isEditorInput) { + const label = t("portal.pipelines.builder.runOn", "Runs on"); + return ( + + + {label} + + + + } + > + + onInputChange({ + triggerType: value && value !== MANUAL_OPTION ? value : MANUAL, + }) + } + options={triggerOptions} + /> + + + {input.triggerType === "schedule" && ( +

+ + {t("portal.pipelines.composer.scheduleEvery")} + + onInputChange({ scheduleCount: e.target.value })} + className="portal-builder__schedule-count" + /> + - updateInput({ - triggerType: - value && value !== MANUAL_OPTION ? value : MANUAL, - }) - } - options={triggerOptionsFor(input.sourceId)} - /> - - - {input.triggerType === "schedule" && ( -
- - {t("portal.pipelines.composer.scheduleEvery")} - - - updateInput({ scheduleCount: e.target.value }) - } - className="portal-builder__schedule-count" - /> - itself. + const sizeInput = page.getByTestId("pdf-editor-font-size"); + await expect(sizeInput).toBeEnabled(); + await sizeInput.fill("24"); + await sizeInput.press("Enter"); + + // The command scales via a matrix ratio so allow a small tolerance. + await expect + .poll(async () => await readFontSize(runId), { timeout: 5_000 }) + .not.toBe(sizeBefore); + const sizeAfter = await readFontSize(runId); + expect(sizeAfter).not.toBeNull(); + expect(Math.abs(sizeAfter! - 24)).toBeLessThan(0.5); + }); +}); + +test.describe("PDF text editor - save", () => { + test("Save PDF produces a downloadable file", async ({ page }) => { + await gotoEditor(page); + await loadSamplePdf(page); + + const firstRunTestId = await page + .locator('[data-testid^="pdf-editor-run-p0-"]') + .first() + .getAttribute("data-testid"); + await typeIntoRun(page, firstRunTestId!, "A"); + + const downloadPromise = page.waitForEvent("download"); + await page.getByTestId("pdf-editor-download").click(); + const download = await downloadPromise; + + expect(download.suggestedFilename()).toMatch(/\.pdf$/i); + const stream = await download.createReadStream(); + const chunks: Buffer[] = []; + for await (const chunk of stream) chunks.push(chunk as Buffer); + const buf = Buffer.concat(chunks); + expect(buf.length).toBeGreaterThan(100); + expect(buf.subarray(0, 4).toString("ascii")).toBe("%PDF"); + }); + + test("saved PDF round-trips: re-opening it preserves the edit", async ({ + page, + }) => { + await gotoEditor(page); + await loadSamplePdf(page); + + // Capture the original first-run text, then edit it. + const firstRun = page + .locator('[data-testid^="pdf-editor-run-p0-"]') + .first(); + const runTestId = (await firstRun.getAttribute("data-testid")) ?? ""; + // Trailing newline only: WebKit adds one, and this spec asserts on spaces. + const original = ((await firstRun.innerText()) ?? "").replace(/\n+$/, ""); + const appended = " (Hello!)"; + const edited = `${original}${appended}`; + + await typeIntoRun(page, runTestId, appended); + await expect(firstRun).toContainText(appended); + + // Trigger the save and capture the bytes. + const downloadPromise = page.waitForEvent("download"); + await page.getByTestId("pdf-editor-download").click(); + const download = await downloadPromise; + const stream = await download.createReadStream(); + const chunks: Buffer[] = []; + for await (const chunk of stream) chunks.push(chunk as Buffer); + const savedBytes = Buffer.concat(chunks); + + // Push the saved bytes back into the dropzone as a new file. setInputFiles + // accepts an in-memory payload. + await page.locator('[data-testid="pdf-editor-file-input"]').setInputFiles({ + name: "round-trip.pdf", + mimeType: "application/pdf", + buffer: savedBytes, + }); + + // The edited text must be present somewhere in page 0's runs. + const allText = await page + .waitForFunction( + () => { + const runs = Array.from( + document.querySelectorAll( + '[data-testid^="pdf-editor-run-p0-"]', + ), + ); + if (runs.length === 0) return null; + const joined = runs.map((el) => el.innerText).join(" "); + return /Hello/.test(joined) ? joined : null; + }, + { timeout: 30_000, polling: 500 }, + ) + .then((h) => h.jsonValue() as Promise); + expect(allText).toContain(original); + expect(allText).toContain("(Hello!)"); + // The boundary between original and appended may collapse to a single or + // double space depending on per-word emit / LineGrouper reconstruction. + expect(allText).not.toContain(`${original.trimEnd()}(Hello!)`); + // Quiet the unused-var lint - `edited` documents the intent above. + void edited; + }); + + test("a saved edit is readable by an independent PDF parser (not just the editor)", async ({ + page, + }) => { + // The other round-trip tests re-feed the saved bytes through the SAME + // PdfiumTextReader+LineGrouper that wrote them. + await gotoEditor(page); + await loadSamplePdf(page); + + const firstRun = page + .locator('[data-testid^="pdf-editor-run-p0-"]') + .first(); + const runTestId = (await firstRun.getAttribute("data-testid")) ?? ""; + await typeIntoRun(page, runTestId, "ZZMARKER"); + await expect(firstRun).toContainText("ZZMARKER"); + + const downloadPromise = page.waitForEvent("download"); + await page.getByTestId("pdf-editor-download").click(); + const download = await downloadPromise; + const stream = await download.createReadStream(); + const chunks: Buffer[] = []; + for await (const chunk of stream) chunks.push(chunk as Buffer); + const savedBytes = Buffer.concat(chunks); + + // Independent structural cross-check: pdf-lib must load the bytes and see + // the single page. + const doc = await PDFDocument.load(savedBytes); + expect(doc.getPageCount()).toBe(1); + }); +}); + +test.describe("PDF text editor - whitespace preservation", () => { + // These guard against a recurring class of regression where typed spaces + // vanish from the saved PDF. + + async function readFirstRunText( + page: import("@playwright/test").Page, + ): Promise { + return await page.evaluate(() => { + const store = ( + window as unknown as { + __editor_store?: { + state: { pages: { runs: { text: string }[] }[] }; + }; + } + ).__editor_store!; + return store.state.pages[0]?.runs[0]?.text ?? ""; + }); + } + + async function saveAndReopen( + page: import("@playwright/test").Page, + ): Promise { + const downloadPromise = page.waitForEvent("download"); + await page.getByTestId("pdf-editor-download").click(); + const download = await downloadPromise; + const stream = await download.createReadStream(); + const chunks: Buffer[] = []; + for await (const chunk of stream) chunks.push(chunk as Buffer); + const savedBytes = Buffer.concat(chunks); + await page.locator('[data-testid="pdf-editor-file-input"]').setInputFiles({ + name: "round-trip.pdf", + mimeType: "application/pdf", + buffer: savedBytes, + }); + await expect( + page.locator('[data-testid^="pdf-editor-run-p0-"]').first(), + ).toBeVisible({ timeout: 30_000 }); + } + + test("NBSP typed into a single-line run is normalized to a regular space in the model", async ({ + page, + }) => { + await gotoEditor(page); + await loadSamplePdf(page); + + const firstRun = page + .locator('[data-testid^="pdf-editor-run-p0-"]') + .first(); + const runTestId = (await firstRun.getAttribute("data-testid")) ?? ""; + + // Insert a literal NBSP via execCommand (same dispatch path the + // browser's IME / autocorrect uses when it substitutes one). + await typeIntoRun(page, runTestId, "X\u00A0Y"); + + // The visible overlay shows what we typed. + await expect(firstRun).toContainText("X"); + await expect(firstRun).toContainText("Y"); + + // But the model snapshot - the source of truth for save - must contain + // regular space, never NBSP. + const modelText = await readFirstRunText(page); + expect(modelText).not.toContain("\u00A0"); + expect(modelText).toContain("X Y"); + }); + + test("typed single space survives save and re-open", async ({ page }) => { + await gotoEditor(page); + await loadSamplePdf(page); + + const firstRun = page + .locator('[data-testid^="pdf-editor-run-p0-"]') + .first(); + const runTestId = (await firstRun.getAttribute("data-testid")) ?? ""; + // Trailing newline only: WebKit adds one, and this spec asserts on spaces. + const original = ((await firstRun.innerText()) ?? "").replace(/\n+$/, ""); + const appended = " Hello World"; + await typeIntoRun(page, runTestId, appended); + await expect(firstRun).toContainText("Hello World"); + + await saveAndReopen(page); + + // After re-open we re-read everything through PdfiumTextReader + + // LineGrouper. + const reopenedAllText = await page + .waitForFunction( + () => { + const runs = Array.from( + document.querySelectorAll( + '[data-testid^="pdf-editor-run-p0-"]', + ), + ); + if (runs.length === 0) return null; + const joined = runs.map((el) => el.innerText).join("\n"); + return /Hello/.test(joined) ? joined : null; + }, + { timeout: 30_000, polling: 500 }, + ) + .then((h) => h.jsonValue() as Promise); + expect(reopenedAllText).toContain("Hello World"); + expect(reopenedAllText).not.toContain("Hello\u00A0World"); + expect(reopenedAllText).not.toMatch(/HelloWorld/); + expect(reopenedAllText).toContain(original); + }); + + test("deleting one char from a positional-jump run keeps inter-word spaces", async ({ + page, + }) => { + // Repro for the recurring "all spaces vanish when I delete a single letter" + // bug on the Stirling marketing PDF. + await gotoEditor(page); + await page + .locator('[data-testid="pdf-editor-file-input"]') + .setInputFiles( + path.join( + import.meta.dirname, + "../test-fixtures/stirling-marketing.pdf", + ), + ); + await expect(page.getByTestId("pdf-editor-page-0")).toBeVisible({ + timeout: 30_000, + }); + + // Find the run containing the marketing tagline. Look across every + // page since the marketing PDF is multi-page. + const target = await page.evaluate(() => { + const els = Array.from( + document.querySelectorAll( + '[data-testid^="pdf-editor-run-p"]', + ), + ); + for (const el of els) { + const txt = (el.innerText ?? "").trim(); + if ( + /Adobe/i.test(txt) && + /Acrobat/i.test(txt) && + /Alternative/i.test(txt) + ) { + return { testId: el.dataset.testid ?? "", text: txt }; + } + } + return null; + }); + if (!target) { + test.skip(true, "marketing PDF missing the Acrobat Alternative line"); + return; + } + expect(target.text).toMatch(/Adobe\s+Acrobat\s+Alternative/); + + // Trigger the exact failure path: replace the whole text with itself minus + // the last char. typeIntoRun with selectNodeContents + insertText. + const trimmed = target.text.slice(0, -1); + await typeIntoRun(page, target.testId, trimmed); + + // Model assertion: after the edit, the run's text in the editor store must + // STILL contain the inter-word spaces. + const modelText = await page.evaluate((tid) => { + const store = ( + window as unknown as { + __editor_store?: { + state: { pages: { runs: { id: string; text: string }[] }[] }; + }; + } + ).__editor_store!; + for (const p of store.state.pages) { + for (const r of p.runs) { + if (`pdf-editor-run-${r.id}` === tid) return r.text; + } + } + return ""; + }, target.testId); + expect(modelText).toMatch(/Adobe\s+Acrobat\s+Alternativ/); + + // Save and re-open. The reopened text on page 0 must still parse back to a + // tagline with spaces between words. + await saveAndReopen(page); + + // The marketing PDF is multi-page; pages render lazily and the tagline run + // we care about may not have mounted yet. + const reopenedAllText = await page + .waitForFunction( + () => { + // Force every page into view so its overlays mount. + const pageEls = Array.from( + document.querySelectorAll( + '[data-testid^="pdf-editor-page-"]', + ), + ); + for (const el of pageEls) el.scrollIntoView({ block: "center" }); + const runs = Array.from( + document.querySelectorAll( + '[data-testid^="pdf-editor-run-p"]', + ), + ); + const joined = runs.map((el) => el.innerText).join("\n"); + // Resolve once we can see "Adobe" somewhere on the page - + // signals the tagline overlay has rendered. + return /Adobe/i.test(joined) ? joined : null; + }, + { timeout: 30_000, polling: 500 }, + ) + .then((handle) => handle.jsonValue() as Promise); + // Surface a slice around the tagline on assertion failure so a future + // regression debugger sees the actual reopened text. + const tagIdx = reopenedAllText.indexOf("Free"); + const taglineSnippet = + tagIdx >= 0 + ? reopenedAllText.slice(Math.max(0, tagIdx - 20), tagIdx + 200) + : ""; + // Core check: none of the word pairs should be GLUED (no whitespace + // separator at all between them). + expect( + reopenedAllText, + `Tagline snippet: ${JSON.stringify(taglineSnippet)}`, + ).not.toMatch(/FreeAdobe/); + expect(reopenedAllText).not.toMatch(/AdobeAcrobat/); + // Positive check: the tagline words DO appear separated by some whitespace + // somewhere in the reopened text. + expect(reopenedAllText).toMatch(/Free\s+Adobe/); + expect(reopenedAllText).toMatch(/Adobe\s+Acrobat/); + }); + + test("user-sample.pdf: deleting one char from tagline keeps every inter-word space", async ({ + page, + }) => { + // EXACT user repro. + await gotoEditor(page); + await page + .locator('[data-testid="pdf-editor-file-input"]') + .setInputFiles(USER_SAMPLE_PDF); + await expect(page.getByTestId("pdf-editor-page-0")).toBeVisible({ + timeout: 30_000, + }); + + // Find the tagline overlay. + const taglineHandle = await page + .locator('[data-testid^="pdf-editor-run-p0-"]') + .filter({ hasText: /Adobe.+Acrobat.+Alternative/ }) + .first() + .elementHandle(); + if (!taglineHandle) { + test.skip(true, "Sample.pdf is missing the Acrobat Alternative tagline"); + return; + } + const taglineTestId = + (await taglineHandle.getAttribute("data-testid")) ?? ""; + const original = (await taglineHandle.innerText()) ?? ""; + expect(original).toMatch(/Adobe\s+Acrobat\s+Alternative/); + + // Delete the last character (matches the user clicking the line and + // hitting Backspace once). + await page.evaluate((tid) => { + const el = document.querySelector( + `[data-testid="${tid}"]`, + ); + if (!el) throw new Error("no tagline element"); + el.focus(); + const sel = window.getSelection(); + if (!sel) return; + const range = document.createRange(); + range.selectNodeContents(el); + range.collapse(false); + sel.removeAllRanges(); + sel.addRange(range); + document.execCommand("delete", false); + }, taglineTestId); + + // Model assertion: the run text after the edit must still have all four + // inter-word gaps. + const modelText = await page.evaluate((tid) => { + const store = ( + window as unknown as { + __editor_store: { + state: { pages: { runs: { id: string; text: string }[] }[] }; + }; + } + ).__editor_store; + for (const p of store.state.pages) { + for (const r of p.runs) { + if (`pdf-editor-run-${r.id}` === tid) return r.text; + } + } + return ""; + }, taglineTestId); + expect(modelText).toMatch(/The\s+Free\s+Adobe\s+Acrobat\s+Alternativ/); + + // Round-trip through save + re-open and verify the same word boundaries + // survive. + await saveAndReopen(page); + + const reopenedAllText = await page + .waitForFunction( + () => { + const pages = Array.from( + document.querySelectorAll( + '[data-testid^="pdf-editor-page-"]', + ), + ); + for (const el of pages) el.scrollIntoView({ block: "center" }); + const runs = Array.from( + document.querySelectorAll( + '[data-testid^="pdf-editor-run-p"]', + ), + ); + const joined = runs.map((el) => el.innerText).join("\n"); + return /Adobe/i.test(joined) ? joined : null; + }, + { timeout: 30_000, polling: 500 }, + ) + .then((h) => h.jsonValue() as Promise); + + // Surface a slice around the tagline on failure so future + // debuggers see the actual reopened text. + const tagIdx = reopenedAllText.indexOf("Adobe"); + const snippet = + tagIdx >= 0 + ? reopenedAllText.slice(Math.max(0, tagIdx - 30), tagIdx + 200) + : ""; + + // The CORE assertion. The previous bug rendered all words glued. + expect( + reopenedAllText, + `Tagline snippet: ${JSON.stringify(snippet)}`, + ).not.toMatch(/FreeAdobe/); + expect(reopenedAllText).not.toMatch(/AdobeAcrobat/); + expect(reopenedAllText).not.toMatch(/AcrobatAlternativ/); + // Positive form: words separated by some whitespace. + expect(reopenedAllText).toMatch(/Free\s+Adobe/); + expect(reopenedAllText).toMatch(/Adobe\s+Acrobat/); + expect(reopenedAllText).toMatch(/Acrobat\s+Alternativ/); + }); + + test("user-sample.pdf: deleting one char from middle of a LineGrouper-merged line doesn't corrupt or duplicate sub-runs", async ({ + page, + }) => { + // Regression guard: a previous attempt to teach partialEdit about + // LineGrouper-synthesised whitespace miscounted ghost chars by 1. + await gotoEditor(page); + await page + .locator('[data-testid="pdf-editor-file-input"]') + .setInputFiles(USER_SAMPLE_PDF); + await expect(page.getByTestId("pdf-editor-page-0")).toBeVisible({ + timeout: 30_000, + }); + await page.waitForTimeout(500); + + // Snapshot the baseline for the bullet that's known to trigger the + // ghost-char-count bug. + const baseline = await page.evaluate(() => { + const store = ( + window as unknown as { + __editor_store: { + doc: { + page: (i: number) => { + runs: Array<{ + id: string; + text: string; + mergedFromTexts: string[]; + }>; + }; + }; + }; + } + ).__editor_store; + const r = store.doc + .page(0) + .runs.find((x) => /Adobe.*Acrobat.*Alternative/.test(x.text)); + return r + ? { id: r.id, text: r.text, mergedFromTexts: [...r.mergedFromTexts] } + : null; + }); + if (!baseline) { + test.skip(true, "fixture missing Adobe/Acrobat/Alternative tagline"); + return; + } + + // Pick a deterministic middle-position character to delete: the letter "A" + // of "Adobe". + const deleteIdx = baseline.text.indexOf("Adobe"); + expect(deleteIdx).toBeGreaterThan(0); + const expectedText = + baseline.text.slice(0, deleteIdx) + baseline.text.slice(deleteIdx + 1); + + // Position caret AFTER "M" and Backspace (so the M gets deleted). + await page.evaluate( + ({ tid, caretAt }) => { + const el = document.querySelector( + `[data-testid="pdf-editor-run-${tid}"]`, + ); + if (!el) throw new Error("no run el"); + el.focus(); + // Walk the text nodes and place caret after the N-th char. + const walker = document.createTreeWalker( + el, + NodeFilter.SHOW_TEXT, + null, + ); + let node: Text | null = null; + let remaining = caretAt; + while (walker.nextNode()) { + const n = walker.currentNode as Text; + const len = n.textContent?.length ?? 0; + if (remaining <= len) { + node = n; + break; + } + remaining -= len; + } + if (!node) throw new Error("ran out of text walking caret"); + const sel = window.getSelection(); + if (!sel) return; + const range = document.createRange(); + range.setStart(node, remaining); + range.setEnd(node, remaining); + sel.removeAllRanges(); + sel.addRange(range); + // delete = deleteContentBackward semantically + document.execCommand("delete", false); + }, + { tid: baseline.id, caretAt: deleteIdx + 1 }, + ); + await page.waitForTimeout(400); + + const after = await page.evaluate((tid) => { + const store = ( + window as unknown as { + __editor_store: { + doc: { + page: (i: number) => { + runs: Array<{ + id: string; + text: string; + mergedFromTexts: string[]; + }>; + }; + }; + }; + } + ).__editor_store; + const r = store.doc.page(0).runs.find((x) => x.id === tid); + return r + ? { text: r.text, mergedFromTexts: [...r.mergedFromTexts] } + : null; + }, baseline.id); + if (!after) throw new Error("post-edit run vanished"); + + // Text content: exactly baseline minus the A of Adobe. + expect(after.text).toBe(expectedText); + + // Sub-run integrity: any non-trivial (>=3 char) baseline fragment must NOT + // appear twice in the post-edit mergedFromTexts. + const counts = new Map(); + for (const t of after.mergedFromTexts) { + if (t.length < 3) continue; + counts.set(t, (counts.get(t) ?? 0) + 1); + } + const dupes = Array.from(counts.entries()).filter(([, c]) => c > 1); + expect( + dupes, + `mergedFromTexts duplicates after edit: ${JSON.stringify(dupes)}`, + ).toEqual([]); + + // Char-fidelity: every non-trivial baseline fragment that wasn't the + // deleted sub-run must still appear verbatim somewhere in. + const afterJoined = after.mergedFromTexts.join("|"); + for (const t of baseline.mergedFromTexts) { + if (t.length < 3) continue; + // The sub-run that contained the deleted A may be removed or + // re-emitted - don't assert on those specifically. + if (t === "A" || t.includes("Adobe")) continue; + expect( + afterJoined, + `baseline fragment ${JSON.stringify(t)} lost from post-edit run`, + ).toContain(t); + } + + // Font preservation: editing a line rendered in a non-base14 source font + // must NOT flip the run to base14:Helvetica. + const afterFontId = await page.evaluate((tid) => { + const store = ( + window as unknown as { + __editor_store: { + doc: { + page: (i: number) => { + runs: Array<{ id: string; fontId: string }>; + }; + }; + }; + } + ).__editor_store; + return ( + store.doc.page(0).runs.find((r) => r.id === tid)?.fontId ?? "" + ); + }, baseline.id); + expect(afterFontId).not.toMatch(/^base14:/); + }); + + test("user-sample.pdf: inserting ' Hi' at end of tagline renders a visible space (not 'AlternativeHi')", async ({ + page, + }) => { + // Repro for the recurring "typed space vanishes" bug on the marketing + // tagline. + await gotoEditor(page); + await page + .locator('[data-testid="pdf-editor-file-input"]') + .setInputFiles(USER_SAMPLE_PDF); + await expect(page.getByTestId("pdf-editor-page-0")).toBeVisible({ + timeout: 30_000, + }); + await page.waitForTimeout(500); + + const tagline = await page.evaluate(() => { + const store = ( + window as unknown as { + __editor_store: { + doc: { + page: (i: number) => { + runs: Array<{ id: string; text: string }>; + }; + }; + }; + } + ).__editor_store; + const r = store.doc + .page(0) + .runs.find((x) => /Adobe.*Acrobat.*Alternative/.test(x.text)); + return r ? { id: r.id, text: r.text } : null; + }); + if (!tagline) { + test.skip( + true, + "user-sample.pdf missing Adobe/Acrobat/Alternative tagline", + ); + return; + } + + // Place caret at end-of-text and type " Hi" via insertText (same + // dispatch path the browser uses for real keystrokes). + await page.evaluate( + ({ tid, caretPos }) => { + const el = document.querySelector( + `[data-testid="pdf-editor-run-${tid}"]`, + ); + if (!el) throw new Error("no tagline element"); + el.focus(); + const walker = document.createTreeWalker( + el, + NodeFilter.SHOW_TEXT, + null, + ); + let node: Text | null = null; + let remaining = caretPos; + while (walker.nextNode()) { + const n = walker.currentNode as Text; + const len = n.textContent?.length ?? 0; + if (remaining <= len) { + node = n; + break; + } + remaining -= len; + } + if (!node) throw new Error("ran out of text walking caret"); + const sel = window.getSelection(); + if (!sel) return; + const range = document.createRange(); + range.setStart(node, remaining); + range.setEnd(node, remaining); + sel.removeAllRanges(); + sel.addRange(range); + document.execCommand("insertText", false, " Hi"); + }, + { tid: tagline.id, caretPos: tagline.text.length }, + ); + await page.waitForTimeout(400); + + // Model assertion: the run text now ends in " Hi" with the literal + // space preserved. + const after = await page.evaluate((tid) => { + const store = ( + window as unknown as { + __editor_store: { + doc: { + page: (i: number) => { + runs: Array<{ + id: string; + text: string; + mergedFromTexts: string[]; + mergedFromBounds: Array<{ x: number; right: number }>; + bounds: { x: number; width: number }; + }>; + }; + }; + }; + } + ).__editor_store; + const r = store.doc.page(0).runs.find((x) => x.id === tid); + return r + ? { + text: r.text, + mergedFromTexts: [...r.mergedFromTexts], + mergedFromBounds: r.mergedFromBounds.map((b) => ({ ...b })), + boundsRight: r.bounds.x + r.bounds.width, + } + : null; + }, tagline.id); + if (!after) throw new Error("tagline run vanished after insert"); + expect(after.text).toMatch(/Alternative\s+Hi$/); + + // The CORE physical-width assertion. + const insertedRight = Math.max( + ...after.mergedFromBounds.map((b) => b.right), + ); + const insertedLeft = Math.min(...after.mergedFromBounds.map((b) => b.x)); + // The bounds span must be at least the width the line had before + // the insert (we APPENDED chars; nothing should subtract width). + expect(insertedRight - insertedLeft).toBeGreaterThan(0); + // run.bounds.width covers up to and including the new chars. + expect(after.boundsRight).toBeGreaterThan(insertedLeft + 5); + + // Round-trip: save the PDF and re-open. + await saveAndReopen(page); + + const reopened = await page + .waitForFunction( + () => { + const runs = Array.from( + document.querySelectorAll( + '[data-testid^="pdf-editor-run-p"]', + ), + ); + const joined = runs.map((el) => el.innerText).join("\n"); + return /Alternativ/i.test(joined) ? joined : null; + }, + { timeout: 30_000, polling: 500 }, + ) + .then((h) => h.jsonValue() as Promise); + + // Surface a snippet on failure so future debuggers see actual + // reopened text instead of an opaque regex mismatch. + const aIdx = reopened.indexOf("Alternat"); + const snippet = + aIdx >= 0 + ? reopened.slice(Math.max(0, aIdx - 5), aIdx + 40) + : ""; + + // The CORE assertion. Glued tokens = whitespace eaten on save. + expect( + reopened, + `Tagline+Hi snippet: ${JSON.stringify(snippet)}`, + ).not.toMatch(/AlternativeHi/); + // Positive form: the two tokens appear with some whitespace separator. + expect(reopened).toMatch(/Alternative\s+Hi/); + }); + + // Sequential-edit visual-integrity tests. + + async function findTaglineRun(page: import("@playwright/test").Page) { + return await page.evaluate(() => { + const store = ( + window as unknown as { + __editor_store: { + doc: { + page: (i: number) => { + runs: Array<{ + id: string; + text: string; + fontId: string; + bounds: { + x: number; + y: number; + width: number; + height: number; + }; + mergedFromTexts: string[]; + }>; + }; + }; + }; + } + ).__editor_store; + const r = store.doc + .page(0) + .runs.find((x) => /Adobe.*Acrobat.*Alternative/.test(x.text)); + return r + ? { + id: r.id, + text: r.text, + fontId: r.fontId, + bounds: { ...r.bounds }, + } + : null; + }); + } + + async function readRun(page: import("@playwright/test").Page, id: string) { + return await page.evaluate((tid) => { + const store = ( + window as unknown as { + __editor_store: { + doc: { + page: (i: number) => { + runs: Array<{ + id: string; + text: string; + fontId: string; + bounds: { x: number; y: number }; + mergedFromTexts: string[]; + }>; + }; + }; + }; + } + ).__editor_store; + const r = store.doc.page(0).runs.find((x) => x.id === tid); + return r + ? { + text: r.text, + fontId: r.fontId, + boundsX: r.bounds.x, + boundsY: r.bounds.y, + mergedFromTexts: [...r.mergedFromTexts], + } + : null; + }, id); + } + + async function caretAt( + page: import("@playwright/test").Page, + tid: string, + pos: number, + ) { + await page.evaluate( + ({ tid, pos }) => { + const el = document.querySelector( + `[data-testid="pdf-editor-run-${tid}"]`, + ); + if (!el) throw new Error("no run el"); + el.focus(); + const walker = document.createTreeWalker( + el, + NodeFilter.SHOW_TEXT, + null, + ); + let node: Text | null = null; + let remaining = pos; + while (walker.nextNode()) { + const n = walker.currentNode as Text; + const len = n.textContent?.length ?? 0; + if (remaining <= len) { + node = n; + break; + } + remaining -= len; + } + if (!node) throw new Error("ran out of text walking caret"); + const sel = window.getSelection(); + if (!sel) return; + const range = document.createRange(); + range.setStart(node, remaining); + range.setEnd(node, remaining); + sel.removeAllRanges(); + sel.addRange(range); + }, + { tid, pos }, + ); + } + + async function execAt( + page: import("@playwright/test").Page, + tid: string, + pos: number, + cmd: "insertText" | "delete", + text?: string, + ) { + await caretAt(page, tid, pos); + await page.evaluate( + ({ cmd, text }) => { + document.execCommand(cmd, false, text); + }, + { cmd, text }, + ); + await page.waitForTimeout(250); + } + + function dedupeCheck(mergedFromTexts: string[]): string[] { + const counts = new Map(); + for (const t of mergedFromTexts) { + if (t.length < 3) continue; + counts.set(t, (counts.get(t) ?? 0) + 1); + } + return Array.from(counts.entries()) + .filter(([, c]) => c > 1) + .map(([t]) => t); + } + + test("SENTINEL: USER_SAMPLE_PDF tagline is a single grouped run", async ({ + page, + }) => { + // 20 tagline tests below guard themselves with `if (!findTaglineRun(page)) + // { test.skip(...) }`. + await gotoEditor(page); + await page + .locator('[data-testid="pdf-editor-file-input"]') + .setInputFiles(USER_SAMPLE_PDF); + await expect(page.getByTestId("pdf-editor-page-0")).toBeVisible({ + timeout: 30_000, + }); + await page.waitForTimeout(500); + + const baseline = await findTaglineRun(page); + expect( + baseline, + "USER_SAMPLE_PDF must expose the Acrobat-Alternative tagline as a single run - if this fails the 20 tagline tests are silently skipping", + ).not.toBeNull(); + expect(baseline!.text).toMatch(/Adobe.*Acrobat.*Alternative/); + }); + + test("user-sample.pdf: sequential type-3-chars-at-end keeps font + position stable", async ({ + page, + }) => { + await gotoEditor(page); + await page + .locator('[data-testid="pdf-editor-file-input"]') + .setInputFiles(USER_SAMPLE_PDF); + await expect(page.getByTestId("pdf-editor-page-0")).toBeVisible({ + timeout: 30_000, + }); + await page.waitForTimeout(500); + + const baseline = await findTaglineRun(page); + if (!baseline) { + test.skip(true, "fixture missing tagline"); + return; + } + expect(baseline.fontId).not.toMatch(/^base14:/); + + // Type three chars at the end of the line. + let runningText = baseline.text; + for (const ch of ["A", "d", "o"]) { + runningText += ch; + await execAt(page, baseline.id, runningText.length - 1, "insertText", ch); + const after = await readRun(page, baseline.id); + if (!after) throw new Error("run vanished after type"); + expect(after.text, `after typing ${ch}`).toBe(runningText); + expect(after.fontId, `font flipped after typing ${ch}`).not.toMatch( + /^base14:/, + ); + expect( + Math.abs(after.boundsY - baseline.bounds.y), + `vertical teleport after typing ${ch} (Δy=${after.boundsY - baseline.bounds.y})`, + ).toBeLessThan(2); + const dupes = dedupeCheck(after.mergedFromTexts); + expect( + dupes, + `dupes after typing ${ch}: ${JSON.stringify(dupes)}`, + ).toEqual([]); + } + }); + + test("user-sample.pdf: sequential backspace-3-chars-from-end keeps font + position stable", async ({ + page, + }) => { + await gotoEditor(page); + await page + .locator('[data-testid="pdf-editor-file-input"]') + .setInputFiles(USER_SAMPLE_PDF); + await expect(page.getByTestId("pdf-editor-page-0")).toBeVisible({ + timeout: 30_000, + }); + await page.waitForTimeout(500); + + const baseline = await findTaglineRun(page); + if (!baseline) { + test.skip(true, "fixture missing tagline"); + return; + } + + let runningText = baseline.text; + for (let i = 0; i < 3; i++) { + runningText = runningText.slice(0, -1); + await execAt(page, baseline.id, runningText.length + 1, "delete"); + const after = await readRun(page, baseline.id); + if (!after) throw new Error("run vanished after backspace"); + expect(after.text, `after backspace #${i + 1}`).toBe(runningText); + expect( + after.fontId, + `font flipped after backspace #${i + 1}`, + ).not.toMatch(/^base14:/); + expect( + Math.abs(after.boundsY - baseline.bounds.y), + `vertical teleport after backspace #${i + 1}`, + ).toBeLessThan(2); + const dupes = dedupeCheck(after.mergedFromTexts); + expect( + dupes, + `dupes after backspace #${i + 1}: ${JSON.stringify(dupes)}`, + ).toEqual([]); + } + }); + + test("user-sample.pdf: sequential delete-3-chars-from-middle keeps font + position stable", async ({ + page, + }) => { + await gotoEditor(page); + await page + .locator('[data-testid="pdf-editor-file-input"]') + .setInputFiles(USER_SAMPLE_PDF); + await expect(page.getByTestId("pdf-editor-page-0")).toBeVisible({ + timeout: 30_000, + }); + await page.waitForTimeout(500); + + const baseline = await findTaglineRun(page); + if (!baseline) { + test.skip(true, "fixture missing tagline"); + return; + } + // Find the index of "Acrobat" - we'll backspace the leading + // letters off it ('A', 'c', 'r') one at a time. + const acrobatStart = baseline.text.indexOf("Acrobat"); + expect(acrobatStart).toBeGreaterThan(0); + + let runningText = baseline.text; + for (let i = 0; i < 3; i++) { + // Each iteration we delete the char at position `acrobatStart` - which is + // the next char of what used to be "Acrobat" after the previous deletes. + runningText = + runningText.slice(0, acrobatStart) + + runningText.slice(acrobatStart + 1); + await execAt(page, baseline.id, acrobatStart + 1, "delete"); + const after = await readRun(page, baseline.id); + if (!after) throw new Error("run vanished after middle delete"); + expect(after.text, `after middle delete #${i + 1}`).toBe(runningText); + expect( + after.fontId, + `font flipped after middle delete #${i + 1}`, + ).not.toMatch(/^base14:/); + expect( + Math.abs(after.boundsY - baseline.bounds.y), + `vertical teleport after middle delete #${i + 1}`, + ).toBeLessThan(2); + const dupes = dedupeCheck(after.mergedFromTexts); + expect( + dupes, + `dupes after middle delete #${i + 1}: ${JSON.stringify(dupes)}`, + ).toEqual([]); + } + }); + + test("user-sample.pdf: interleaved delete-then-type sequence keeps font + position stable", async ({ + page, + }) => { + // Mimics realistic user editing: delete a char, type a different one, + // repeat. + await gotoEditor(page); + await page + .locator('[data-testid="pdf-editor-file-input"]') + .setInputFiles(USER_SAMPLE_PDF); + await expect(page.getByTestId("pdf-editor-page-0")).toBeVisible({ + timeout: 30_000, + }); + await page.waitForTimeout(500); + + const baseline = await findTaglineRun(page); + if (!baseline) { + test.skip(true, "fixture missing tagline"); + return; + } + + const sequence: Array<{ + op: "insertText" | "delete"; + pos: number; + ch?: string; + }> = [ + // Delete the trailing "e" of Alternative. + { op: "delete", pos: baseline.text.length }, + // Append a known-existing 'A'. + { op: "insertText", pos: baseline.text.length - 1, ch: "A" }, + { op: "delete", pos: baseline.text.length }, + // Type 'e' back at the end. + { op: "insertText", pos: baseline.text.length - 1, ch: "e" }, + ]; + + let runningText = baseline.text; + for (let i = 0; i < sequence.length; i++) { + const step = sequence[i]; + if (step.op === "insertText") { + runningText = + runningText.slice(0, step.pos) + + (step.ch ?? "") + + runningText.slice(step.pos); + await execAt(page, baseline.id, step.pos, "insertText", step.ch); + } else { + runningText = + runningText.slice(0, step.pos - 1) + runningText.slice(step.pos); + await execAt(page, baseline.id, step.pos, "delete"); + } + const after = await readRun(page, baseline.id); + if (!after) throw new Error(`run vanished after step ${i}`); + expect(after.text, `step ${i} (${step.op})`).toBe(runningText); + expect(after.fontId, `step ${i} font flipped`).not.toMatch(/^base14:/); + expect( + Math.abs(after.boundsY - baseline.bounds.y), + `step ${i} vertical teleport`, + ).toBeLessThan(2); + const dupes = dedupeCheck(after.mergedFromTexts); + expect(dupes, `step ${i} dupes: ${JSON.stringify(dupes)}`).toEqual([]); + } + }); + + test("user-sample.pdf: inserting chars in the MIDDLE shifts subsequent text right (no overlap)", async ({ + page, + }) => { + // Regression guard: inserting NEW chars between two kept sub-runs used to + // leave the inserted text overlapping the original following chars. + await gotoEditor(page); + await page + .locator('[data-testid="pdf-editor-file-input"]') + .setInputFiles(USER_SAMPLE_PDF); + await expect(page.getByTestId("pdf-editor-page-0")).toBeVisible({ + timeout: 30_000, + }); + await page.waitForTimeout(500); + + const baseline = await page.evaluate(() => { + const store = ( + window as unknown as { + __editor_store: { + doc: { + page: (i: number) => { + runs: Array<{ + id: string; + text: string; + bounds: { width: number }; + mergedFromTexts: string[]; + mergedFromBounds: Array<{ x: number; right: number }>; + }>; + }; + }; + }; + } + ).__editor_store; + const r = store.doc + .page(0) + .runs.find((x) => /Adobe.*Acrobat.*Alternative/.test(x.text)); + return r + ? { + id: r.id, + text: r.text, + mergedFromTexts: [...r.mergedFromTexts], + mergedFromBounds: r.mergedFromBounds.map((b) => ({ ...b })), + boundsWidth: r.bounds.width, + } + : null; + }); + if (!baseline) { + test.skip(true, "fixture missing tagline"); + return; + } + + // Find caret position right after "Acrob" (between 'b' and 'a'). + const acrobIdx = baseline.text.indexOf("Acrobat"); + expect(acrobIdx).toBeGreaterThan(0); + const caretPos = acrobIdx + 5; // after "Acrob" + + // Find original x of the sub-run that LIVES AFTER the caret - + // this is the one that should shift right after the insert. + let charCursor = 0; + let postCaretSubRunIdx = -1; + for (let i = 0; i < baseline.mergedFromTexts.length; i++) { + const len = baseline.mergedFromTexts[i].length; + if (caretPos >= charCursor && caretPos <= charCursor + len) { + // Caret is at end of this sub-run; the NEXT sub-run is what + // should shift. + postCaretSubRunIdx = i + 1; + break; + } + charCursor += len; + } + expect(postCaretSubRunIdx).toBeGreaterThan(0); + expect(postCaretSubRunIdx).toBeLessThan(baseline.mergedFromBounds.length); + const origPostCaretX = baseline.mergedFromBounds[postCaretSubRunIdx].x; + + await execAt(page, baseline.id, caretPos, "insertText", "aaa"); + + const after = await page.evaluate((tid) => { + const store = ( + window as unknown as { + __editor_store: { + doc: { + page: (i: number) => { + runs: Array<{ + id: string; + text: string; + mergedFromTexts: string[]; + mergedFromBounds: Array<{ x: number; right: number }>; + bounds: { width: number }; + }>; + }; + }; + }; + } + ).__editor_store; + const r = store.doc.page(0).runs.find((x) => x.id === tid); + return r + ? { + text: r.text, + mergedFromTexts: [...r.mergedFromTexts], + mergedFromBounds: r.mergedFromBounds.map((b) => ({ ...b })), + boundsWidth: r.bounds.width, + } + : null; + }, baseline.id); + if (!after) throw new Error("post-edit run vanished"); + + // Sanity: text is exactly baseline with "aaa" inserted at caretPos. + const expectedText = + baseline.text.slice(0, caretPos) + "aaa" + baseline.text.slice(caretPos); + expect(after.text).toBe(expectedText); + + // The sub-run that USED to live right after the caret must now have its x + // shifted RIGHT to make room for the inserted "aaa". + let newPostCaretX: number | null = null; + // Original next sub-run's text: + const targetText = baseline.mergedFromTexts[postCaretSubRunIdx]; + // Find its first occurrence AFTER the insertion point in the + // after-array (skipping the "aaa" sub-runs). + let cursor = 0; + for (let i = 0; i < after.mergedFromTexts.length; i++) { + if (cursor >= caretPos + 3 && after.mergedFromTexts[i] === targetText) { + newPostCaretX = after.mergedFromBounds[i].x; + break; + } + cursor += after.mergedFromTexts[i].length; + } + expect( + newPostCaretX, + `could not find post-caret sub-run after insertion`, + ).not.toBeNull(); + expect( + newPostCaretX!, + `post-caret sub-run did not shift right (orig=${origPostCaretX}, new=${newPostCaretX})`, + ).toBeGreaterThan(origPostCaretX + 5); + + // Run width must have grown by at least the inserted "aaa" width. + const widthGrowth = after.boundsWidth - baseline.boundsWidth; + expect( + widthGrowth, + `bounds.width didn't grow (Δ=${widthGrowth}) - insert probably overlapped following text`, + ).toBeGreaterThan(10); + }); + + test("user-sample.pdf: deleting an entire word closes the gap (text after shifts left)", async ({ + page, + }) => { + // Regression guard: when an edit fully removes a sub-run, the surviving + // sub-runs to its right used to STAY at their original x position. + await gotoEditor(page); + await page + .locator('[data-testid="pdf-editor-file-input"]') + .setInputFiles(USER_SAMPLE_PDF); + await expect(page.getByTestId("pdf-editor-page-0")).toBeVisible({ + timeout: 30_000, + }); + await page.waitForTimeout(500); + + // Snapshot the baseline including the bounds of every sub-run + // (we need the original x of the sub-run that lives AFTER "Adobe "). + const baseline = await page.evaluate(() => { + const store = ( + window as unknown as { + __editor_store: { + doc: { + page: (i: number) => { + runs: Array<{ + id: string; + text: string; + fontId: string; + bounds: { x: number; width: number }; + mergedFromTexts: string[]; + mergedFromBounds: Array<{ x: number; right: number }>; + }>; + }; + }; + }; + } + ).__editor_store; + const r = store.doc + .page(0) + .runs.find((x) => /Adobe.*Acrobat.*Alternative/.test(x.text)); + return r + ? { + id: r.id, + text: r.text, + mergedFromTexts: [...r.mergedFromTexts], + mergedFromBounds: r.mergedFromBounds.map((b) => ({ ...b })), + boundsWidth: r.bounds.width, + } + : null; + }); + if (!baseline) { + test.skip(true, "fixture missing tagline"); + return; + } + + // Find the first sub-run whose text begins after the "Adobe " + // word. We'll track its bounds.x before and after the delete. + const adobeChars = "Adobe"; + const acrobatChars = "Acrobat"; + const adobeStartCharIdx = baseline.text.indexOf(adobeChars); + const acrobatStartCharIdx = baseline.text.indexOf(acrobatChars); + expect(adobeStartCharIdx).toBeGreaterThan(0); + expect(acrobatStartCharIdx).toBeGreaterThan(adobeStartCharIdx); + + // The sub-run containing the FIRST char of "Acrobat" - its + // original x is what we compare to. + let charCursor = 0; + let acrobatSubRunIdx = -1; + for (let i = 0; i < baseline.mergedFromTexts.length; i++) { + const sub = baseline.mergedFromTexts[i]; + if ( + acrobatStartCharIdx >= charCursor && + acrobatStartCharIdx < charCursor + sub.length + ) { + acrobatSubRunIdx = i; + break; + } + charCursor += sub.length; + } + expect(acrobatSubRunIdx).toBeGreaterThan(0); + const origAcrobatX = baseline.mergedFromBounds[acrobatSubRunIdx].x; + + // Select "Adobe " (the word + trailing whitespace) and delete it. + await page.evaluate( + ({ tid, start, end }) => { + const el = document.querySelector( + `[data-testid="pdf-editor-run-${tid}"]`, + ); + if (!el) throw new Error("no run el"); + el.focus(); + const walker = document.createTreeWalker( + el, + NodeFilter.SHOW_TEXT, + null, + ); + let startNode: Text | null = null; + let startOffset = 0; + let endNode: Text | null = null; + let endOffset = 0; + let remaining = start; + while (walker.nextNode()) { + const n = walker.currentNode as Text; + const len = n.textContent?.length ?? 0; + if (!startNode && remaining <= len) { + startNode = n; + startOffset = remaining; + } + if (!startNode) remaining -= len; + else break; + } + // Reset walker; re-walk for end. + const walker2 = document.createTreeWalker( + el, + NodeFilter.SHOW_TEXT, + null, + ); + let r2 = end; + while (walker2.nextNode()) { + const n = walker2.currentNode as Text; + const len = n.textContent?.length ?? 0; + if (r2 <= len) { + endNode = n; + endOffset = r2; + break; + } + r2 -= len; + } + if (!startNode || !endNode) throw new Error("selection walk failed"); + const sel = window.getSelection(); + if (!sel) return; + const range = document.createRange(); + range.setStart(startNode, startOffset); + range.setEnd(endNode, endOffset); + sel.removeAllRanges(); + sel.addRange(range); + document.execCommand("delete", false); + }, + { + tid: baseline.id, + // Delete the whole word "Adobe" + the trailing space chars + // (sample has TWO spaces between words). + start: adobeStartCharIdx, + end: acrobatStartCharIdx, + }, + ); + await page.waitForTimeout(400); + + const after = await page.evaluate((tid) => { + const store = ( + window as unknown as { + __editor_store: { + doc: { + page: (i: number) => { + runs: Array<{ + id: string; + text: string; + mergedFromTexts: string[]; + mergedFromBounds: Array<{ x: number; right: number }>; + bounds: { width: number }; + }>; + }; + }; + }; + } + ).__editor_store; + const r = store.doc.page(0).runs.find((x) => x.id === tid); + return r + ? { + text: r.text, + mergedFromTexts: [...r.mergedFromTexts], + mergedFromBounds: r.mergedFromBounds.map((b) => ({ ...b })), + boundsWidth: r.bounds.width, + } + : null; + }, baseline.id); + if (!after) throw new Error("post-edit run vanished"); + + // Text content: baseline minus "Adobe " (including the trailing + // double space). + const expectedText = + baseline.text.slice(0, adobeStartCharIdx) + + baseline.text.slice(acrobatStartCharIdx); + expect(after.text).toBe(expectedText); + + // Find the sub-run that now contains "Acrobat" - it MUST have + // shifted LEFT of its original position to close the gap. + let newAcrobatX: number | null = null; + let cursor = 0; + for (let i = 0; i < after.mergedFromTexts.length; i++) { + const sub = after.mergedFromTexts[i]; + const idx = (after.text.slice(cursor) + "").indexOf("Acrobat"); + if ( + idx >= 0 && + cursor + idx >= cursor && + cursor + idx < cursor + sub.length + ) { + newAcrobatX = after.mergedFromBounds[i].x; + break; + } + cursor += sub.length; + } + // Fallback: scan all sub-runs for one whose text starts with 'A' + // and is near the expected position. + if (newAcrobatX === null) { + for (let i = 0; i < after.mergedFromTexts.length; i++) { + if (after.mergedFromTexts[i].startsWith("A")) { + newAcrobatX = after.mergedFromBounds[i].x; + break; + } + } + } + if (newAcrobatX === null) { + // Pick the sub-run at the same INDEX as the original Acrobat sub-run. + const newIdx = Math.min( + acrobatSubRunIdx, + after.mergedFromBounds.length - 1, + ); + newAcrobatX = after.mergedFromBounds[newIdx].x; + } + expect( + newAcrobatX, + `Acrobat sub-run did not shift left (original=${origAcrobatX}, new=${newAcrobatX})`, + ).toBeLessThan(origAcrobatX); + + // Run width must have shrunk by roughly the width of "Adobe " (give or take + // a few pt for the per-word emit's positional padding). + const widthShrinkage = baseline.boundsWidth - after.boundsWidth; + expect( + widthShrinkage, + `bounds.width barely shrank (Δ=${widthShrinkage}) - gap probably left in place`, + ).toBeGreaterThan(15); + }); + + // Comprehensive edit-text regression. + + /** Walk adjacent merged-from-bounds and assert no horizontal overlap. */ + function assertNoBoundsOverlap( + bounds: Array<{ x: number; right: number }>, + label: string, + ): void { + for (let i = 1; i < bounds.length; i++) { + const prev = bounds[i - 1]; + const cur = bounds[i]; + // Tolerate a tiny overlap (kerning, sub-pixel rounding). + const overlap = prev.right - cur.x; + if (overlap > 1.5) { + throw new Error( + `${label}: sub-run ${i - 1} (right=${prev.right.toFixed(2)}) overlaps sub-run ${i} (x=${cur.x.toFixed(2)}) by ${overlap.toFixed(2)}pt`, + ); + } + } + } + + async function snapshotIntegrity( + page: import("@playwright/test").Page, + runId: string, + baselineY: number, + expectedText: string, + stepLabel: string, + ): Promise { + const after = await page.evaluate((tid) => { + const store = ( + window as unknown as { + __editor_store: { + doc: { + page: (i: number) => { + runs: Array<{ + id: string; + text: string; + fontId: string; + bounds: { y: number }; + mergedFromTexts: string[]; + mergedFromBounds: Array<{ x: number; right: number }>; + }>; + }; + }; + }; + } + ).__editor_store; + const r = store.doc.page(0).runs.find((x) => x.id === tid); + return r + ? { + text: r.text, + fontId: r.fontId, + boundsY: r.bounds.y, + mergedFromTexts: [...r.mergedFromTexts], + mergedFromBounds: r.mergedFromBounds.map((b) => ({ ...b })), + } + : null; + }, runId); + if (!after) throw new Error(`${stepLabel}: run vanished`); + expect(after.text, `${stepLabel}: text`).toBe(expectedText); + expect( + after.fontId, + `${stepLabel}: font flipped. text=${JSON.stringify(after.text.slice(0, 80))}; merged[0..12]=${JSON.stringify(after.mergedFromTexts.slice(0, 12))}`, + ).not.toMatch(/^base14:/); + expect( + Math.abs(after.boundsY - baselineY), + `${stepLabel}: vertical teleport (Δy=${after.boundsY - baselineY})`, + ).toBeLessThan(2); + const counts = new Map(); + for (const t of after.mergedFromTexts) { + if (t.length < 3) continue; + counts.set(t, (counts.get(t) ?? 0) + 1); + } + const dupes = Array.from(counts.entries()) + .filter(([, c]) => c > 1) + .map(([t]) => t); + expect(dupes, `${stepLabel}: dupe sub-runs`).toEqual([]); + assertNoBoundsOverlap(after.mergedFromBounds, stepLabel); + } + + test("comprehensive regression: insert at end + step-by-step integrity check", async ({ + page, + }) => { + await gotoEditor(page); + await page + .locator('[data-testid="pdf-editor-file-input"]') + .setInputFiles(USER_SAMPLE_PDF); + await expect(page.getByTestId("pdf-editor-page-0")).toBeVisible({ + timeout: 30_000, + }); + await page.waitForTimeout(500); + + const baseline = await findTaglineRun(page); + if (!baseline) { + test.skip(true, "tagline missing"); + return; + } + + // Type a varied sequence: letters, digit, space, letter. + const chars = ["X", "9", " ", "z", "Q"]; + let running = baseline.text; + for (const ch of chars) { + running += ch; + await execAt(page, baseline.id, running.length - 1, "insertText", ch); + await snapshotIntegrity( + page, + baseline.id, + baseline.bounds.y, + running, + `insert "${ch}" at end`, + ); + } + }); + + test("comprehensive regression: insert at start + step-by-step integrity check", async ({ + page, + }) => { + await gotoEditor(page); + await page + .locator('[data-testid="pdf-editor-file-input"]') + .setInputFiles(USER_SAMPLE_PDF); + await expect(page.getByTestId("pdf-editor-page-0")).toBeVisible({ + timeout: 30_000, + }); + await page.waitForTimeout(500); + + const baseline = await findTaglineRun(page); + if (!baseline) { + test.skip(true, "tagline missing"); + return; + } + + const chars = ["!", "?", "*"]; + let running = baseline.text; + for (const ch of chars) { + running = ch + running; + await execAt(page, baseline.id, 0, "insertText", ch); + await snapshotIntegrity( + page, + baseline.id, + baseline.bounds.y, + running, + `insert "${ch}" at start`, + ); + } + }); + + test("comprehensive regression: insert in middle + step-by-step integrity check", async ({ + page, + }) => { + await gotoEditor(page); + await page + .locator('[data-testid="pdf-editor-file-input"]') + .setInputFiles(USER_SAMPLE_PDF); + await expect(page.getByTestId("pdf-editor-page-0")).toBeVisible({ + timeout: 30_000, + }); + await page.waitForTimeout(500); + + const baseline = await findTaglineRun(page); + if (!baseline) { + test.skip(true, "tagline missing"); + return; + } + const insertAt = baseline.text.indexOf("Acrobat") + 5; // between "Acrob" and "at" + expect(insertAt).toBeGreaterThan(0); + + const chars = ["a", "a", "a"]; // user's reported case + let running = baseline.text; + let offset = 0; + for (const ch of chars) { + running = + running.slice(0, insertAt + offset) + + ch + + running.slice(insertAt + offset); + await execAt(page, baseline.id, insertAt + offset, "insertText", ch); + offset += 1; + await snapshotIntegrity( + page, + baseline.id, + baseline.bounds.y, + running, + `insert "${ch}" in middle (step ${offset})`, + ); + } + }); + + test("comprehensive regression: delete from end down to zero", async ({ + page, + }) => { + await gotoEditor(page); + await page + .locator('[data-testid="pdf-editor-file-input"]') + .setInputFiles(USER_SAMPLE_PDF); + await expect(page.getByTestId("pdf-editor-page-0")).toBeVisible({ + timeout: 30_000, + }); + await page.waitForTimeout(500); + + const baseline = await findTaglineRun(page); + if (!baseline) { + test.skip(true, "tagline missing"); + return; + } + + // Backspace 10 chars from end. + let running = baseline.text; + const totalDeletes = Math.min(10, running.length - 1); + for (let i = 0; i < totalDeletes; i++) { + running = running.slice(0, -1); + await execAt(page, baseline.id, running.length + 1, "delete"); + await snapshotIntegrity( + page, + baseline.id, + baseline.bounds.y, + running, + `backspace #${i + 1}`, + ); + } + }); + + test("comprehensive regression: delete from start", async ({ page }) => { + await gotoEditor(page); + await page + .locator('[data-testid="pdf-editor-file-input"]') + .setInputFiles(USER_SAMPLE_PDF); + await expect(page.getByTestId("pdf-editor-page-0")).toBeVisible({ + timeout: 30_000, + }); + await page.waitForTimeout(500); + + const baseline = await findTaglineRun(page); + if (!baseline) { + test.skip(true, "tagline missing"); + return; + } + + let running = baseline.text; + for (let i = 0; i < 5; i++) { + // Place caret AT position 1 (= after first char), Backspace + // → deletes char 0. + running = running.slice(1); + await execAt(page, baseline.id, 1, "delete"); + await snapshotIntegrity( + page, + baseline.id, + baseline.bounds.y, + running, + `delete-from-start #${i + 1}`, + ); + } + }); + + test("comprehensive regression: delete from middle of various words", async ({ + page, + }) => { + await gotoEditor(page); + await page + .locator('[data-testid="pdf-editor-file-input"]') + .setInputFiles(USER_SAMPLE_PDF); + await expect(page.getByTestId("pdf-editor-page-0")).toBeVisible({ + timeout: 30_000, + }); + await page.waitForTimeout(500); + + const baseline = await findTaglineRun(page); + if (!baseline) { + test.skip(true, "tagline missing"); + return; + } + + // Delete the letter at position N for each word: "Free" → "Fre", "Adobe" → + // "dobe", "Alternative" → "Altrntiv". + const targets: Array<{ + word: string; + offsetInWord: number; + label: string; + }> = [ + { word: "Free", offsetInWord: 4, label: "delete 'e' after Free" }, + { word: "Adobe", offsetInWord: 1, label: "delete 'A' at start of Adobe" }, + { word: "Acrobat", offsetInWord: 3, label: "delete 'r' in Acrobat" }, + ]; + + let running = baseline.text; + for (const t of targets) { + const wordPos = running.indexOf(t.word); + if (wordPos < 0) continue; + const caretPos = wordPos + t.offsetInWord; + const charDeleted = running.charAt(caretPos - 1); + running = running.slice(0, caretPos - 1) + running.slice(caretPos); + await execAt(page, baseline.id, caretPos, "delete"); + await snapshotIntegrity( + page, + baseline.id, + baseline.bounds.y, + running, + `${t.label} (removed '${charDeleted}')`, + ); + } + }); + + test("comprehensive regression: alternating insert/delete sequence", async ({ + page, + }) => { + await gotoEditor(page); + await page + .locator('[data-testid="pdf-editor-file-input"]') + .setInputFiles(USER_SAMPLE_PDF); + await expect(page.getByTestId("pdf-editor-page-0")).toBeVisible({ + timeout: 30_000, + }); + await page.waitForTimeout(500); + + const baseline = await findTaglineRun(page); + if (!baseline) { + test.skip(true, "tagline missing"); + return; + } + + let running = baseline.text; + // 6-step interleaved sequence. + const steps: Array<{ op: "ins" | "del"; pos: () => number; ch?: string }> = + [ + { op: "ins", pos: () => running.length, ch: "Z" }, + { op: "del", pos: () => running.length }, + { op: "ins", pos: () => running.indexOf("Free") + 4, ch: "r" }, + { op: "del", pos: () => running.indexOf("Free") + 5 }, + { op: "ins", pos: () => 0, ch: "*" }, + { op: "del", pos: () => 1 }, + ]; + + for (let i = 0; i < steps.length; i++) { + const s = steps[i]; + const pos = s.pos(); + if (s.op === "ins" && s.ch !== undefined) { + running = running.slice(0, pos) + s.ch + running.slice(pos); + await execAt(page, baseline.id, pos, "insertText", s.ch); + } else { + if (pos < 1) continue; + running = running.slice(0, pos - 1) + running.slice(pos); + await execAt(page, baseline.id, pos, "delete"); + } + // Lighter assertion: text + no teleport + no dupe sub-runs. + // Font flip is tolerated here (see note above). + const after = await readRun(page, baseline.id); + if (!after) throw new Error(`step ${i + 1}: run vanished`); + expect(after.text, `step ${i + 1} (${s.op}) text`).toBe(running); + expect( + Math.abs(after.boundsY - baseline.bounds.y), + `step ${i + 1} vertical teleport (Δy=${after.boundsY - baseline.bounds.y})`, + ).toBeLessThan(2); + const dupes = dedupeCheck(after.mergedFromTexts); + expect(dupes, `step ${i + 1} dupes`).toEqual([]); + } + }); + + test("comprehensive regression: save+reopen text-content round-trip", async ({ + page, + }) => { + // The "would a PDF viewer render the right text" assertion. + await gotoEditor(page); + await page + .locator('[data-testid="pdf-editor-file-input"]') + .setInputFiles(USER_SAMPLE_PDF); + await expect(page.getByTestId("pdf-editor-page-0")).toBeVisible({ + timeout: 30_000, + }); + await page.waitForTimeout(500); + + const baseline = await findTaglineRun(page); + if (!baseline) { + test.skip(true, "tagline missing"); + return; + } + + // Edit: insert "aaa" between "Acrob" and "at". + const insertAt = baseline.text.indexOf("Acrobat") + 5; + await execAt(page, baseline.id, insertAt, "insertText", "aaa"); + + // Save and re-open, then collect every run's text from page 0. + const downloadPromise = page.waitForEvent("download"); + await page.getByTestId("pdf-editor-download").click(); + const download = await downloadPromise; + const stream = await download.createReadStream(); + const chunks: Buffer[] = []; + for await (const chunk of stream) chunks.push(chunk as Buffer); + const buf = Buffer.concat(chunks); + await page.locator('[data-testid="pdf-editor-file-input"]').setInputFiles({ + name: "round-trip.pdf", + mimeType: "application/pdf", + buffer: buf, + }); + await expect( + page.locator('[data-testid^="pdf-editor-run-p0-"]').first(), + ).toBeVisible({ timeout: 30_000 }); + await page.waitForTimeout(1500); + + // Scan EVERY run on EVERY page. + const allText = await page.evaluate(() => { + const store = ( + window as unknown as { + __editor_store: { + state: { pages: { runs: { text: string }[] }[] }; + }; + } + ).__editor_store; + return store.state.pages + .flatMap((p) => p.runs.map((r) => r.text)) + .join("\n"); + }); + const debugSnippet = allText.slice(0, 500); + // The inserted "aaa" must appear near "Acrob". + expect( + allText, + `reopened did not contain "Acrob...aaa". snippet: ${debugSnippet}`, + ).toMatch(/Acrob[\s ]{0,3}a{3,}/); + // Both halves of the tagline must survive the round-trip. + expect( + allText.indexOf("Adobe"), + `Adobe missing. allText: ${debugSnippet}`, + ).toBeGreaterThanOrEqual(0); + expect(allText.indexOf("Acrob")).toBeGreaterThanOrEqual(0); + expect(allText.indexOf("Alternativ")).toBeGreaterThanOrEqual(0); + }); + + // Font-fallback regression tests. + + test("font-fallback: Helvetica fallback for inserted text produces a visible glyph (width > 0)", async ({ + page, + }) => { + // The marketing PDF tagline uses an embedded non-standard font + // ("pdf:...:Unknown"). + await gotoEditor(page); + await page + .locator('[data-testid="pdf-editor-file-input"]') + .setInputFiles(USER_SAMPLE_PDF); + await expect(page.getByTestId("pdf-editor-page-0")).toBeVisible({ + timeout: 30_000, + }); + await page.waitForTimeout(500); + + const baseline = await findTaglineRun(page); + if (!baseline) { + test.skip(true, "tagline missing"); + return; + } + + // Insert "X" at end of tagline. + await execAt(page, baseline.id, baseline.text.length, "insertText", "X"); + + // Read the new sub-run's bounds and confirm it has a real width. + // A 0-width sub-run = font failed to render the glyph = bug. + const result = await page.evaluate((tid) => { + const store = ( + window as unknown as { + __editor_store: { + doc: { + page: (i: number) => { + runs: Array<{ + id: string; + text: string; + mergedFromTexts: string[]; + mergedFromBounds: Array<{ x: number; right: number }>; + }>; + }; + }; + }; + } + ).__editor_store; + const r = store.doc.page(0).runs.find((x) => x.id === tid); + if (!r) return null; + // Find the LAST sub-run whose text contains "X" - the inserted one. + for (let i = r.mergedFromTexts.length - 1; i >= 0; i--) { + if (r.mergedFromTexts[i].includes("X")) { + const b = r.mergedFromBounds[i]; + return { width: b.right - b.x, text: r.mergedFromTexts[i] }; + } + } + return null; + }, baseline.id); + if (!result) throw new Error("inserted 'X' sub-run not found"); + expect( + result.width, + `Inserted "${result.text}" sub-run has 0 width - source font failed to re-encode 'X' as a visible glyph. Should have fallen back to Helvetica.`, + ).toBeGreaterThan(2); + }); + + test("font-borrow: typing same-char-as-original uses the SOURCE font (width matches original)", async ({ + page, + }) => { + // The "try borrow, detect, fall back" path: when every inserted char + // already appears in the source line. + await gotoEditor(page); + await page + .locator('[data-testid="pdf-editor-file-input"]') + .setInputFiles(USER_SAMPLE_PDF); + await expect(page.getByTestId("pdf-editor-page-0")).toBeVisible({ + timeout: 30_000, + }); + await page.waitForTimeout(500); + + const baseline = await findTaglineRun(page); + if (!baseline) { + test.skip(true, "tagline missing"); + return; + } + + // Snapshot the original 'a' width inside "Acrobat" BEFORE editing. + const origAWidth = await page.evaluate((tid) => { + const store = ( + window as unknown as { + __editor_store: { + doc: { + page: (i: number) => { + runs: Array<{ + id: string; + mergedFromTexts: string[]; + mergedFromBounds: Array<{ x: number; right: number }>; + }>; + }; + }; + }; + } + ).__editor_store; + const r = store.doc.page(0).runs.find((x) => x.id === tid); + if (!r) return null; + // Find the FIRST sub-run whose text is exactly 'a'. + for (let i = 0; i < r.mergedFromTexts.length; i++) { + if (r.mergedFromTexts[i] === "a") { + const b = r.mergedFromBounds[i]; + return b.right - b.x; + } + } + return null; + }, baseline.id); + if (origAWidth === null || origAWidth < 1) { + test.skip(true, "no single-char 'a' sub-run found in tagline"); + return; + } + + // Insert 'a' right after the 'a' of "Acrobat". + const caretPos = baseline.text.indexOf("Acrobat") + 6; + await execAt(page, baseline.id, caretPos, "insertText", "a"); + + // Read the INSERTED 'a' sub-run's width. + const insertedAWidth = await page.evaluate((tid) => { + const store = ( + window as unknown as { + __editor_store: { + doc: { + page: (i: number) => { + runs: Array<{ + id: string; + mergedFromTexts: string[]; + mergedFromBounds: Array<{ x: number; right: number }>; + }>; + }; + }; + }; + } + ).__editor_store; + const r = store.doc.page(0).runs.find((x) => x.id === tid); + if (!r) return null; + // The inserted 'a' is the last sub-run with text exactly 'a'. + for (let i = r.mergedFromTexts.length - 1; i >= 0; i--) { + if (r.mergedFromTexts[i] === "a") { + const b = r.mergedFromBounds[i]; + return b.right - b.x; + } + } + return null; + }, baseline.id); + if (insertedAWidth === null) throw new Error("inserted 'a' not found"); + + // The inserted 'a' must be approximately the same width as the original + // 'a'. + const ratio = insertedAWidth / origAWidth; + expect( + ratio, + `inserted 'a' width ${insertedAWidth.toFixed(2)}pt vs original ${origAWidth.toFixed(2)}pt (ratio ${ratio.toFixed(2)}). Helvetica fallback gives ratio ~0.6; source-font borrow gives ~1.0.`, + ).toBeGreaterThan(0.85); + expect(ratio).toBeLessThan(1.2); + }); + + test("font-fallback: typing same-char-as-original keeps text content correct (no garbage glyph)", async ({ + page, + }) => { + // Even when the inserted char IS already present in the source text, the + // result must remain text-content-correct: run.text equals baseline + 'd'. + await gotoEditor(page); + await page + .locator('[data-testid="pdf-editor-file-input"]') + .setInputFiles(USER_SAMPLE_PDF); + await expect(page.getByTestId("pdf-editor-page-0")).toBeVisible({ + timeout: 30_000, + }); + await page.waitForTimeout(500); + + const baseline = await findTaglineRun(page); + if (!baseline) { + test.skip(true, "tagline missing"); + return; + } + + // Find caret right after the 'd' of "Adobe". + const adobeIdx = baseline.text.indexOf("Adobe"); + expect(adobeIdx).toBeGreaterThan(0); + const caretPos = adobeIdx + 2; // after 'A','d' + await execAt(page, baseline.id, caretPos, "insertText", "d"); + + const result = await page.evaluate((tid) => { + const store = ( + window as unknown as { + __editor_store: { + doc: { + page: (i: number) => { + runs: Array<{ + id: string; + text: string; + mergedFromTexts: string[]; + mergedFromBounds: Array<{ x: number; right: number }>; + }>; + }; + }; + }; + } + ).__editor_store; + const r = store.doc.page(0).runs.find((x) => x.id === tid); + if (!r) return null; + // Find the inserted 'd' sub-run - it's the one whose text is exactly "d" + // added between "Ad" and "obe" of the original. + const dSubRuns = r.mergedFromTexts + .map((t, i) => ({ text: t, bounds: r.mergedFromBounds[i] })) + .filter((s) => s.text.includes("d")); + const widths = dSubRuns.map((s) => s.bounds.right - s.bounds.x); + return { text: r.text, dWidths: widths }; + }, baseline.id); + if (!result) throw new Error("run vanished"); + // Text content correct: 'Addobe' appears in the run. + expect(result.text).toMatch(/Ad+obe/); + // At least ONE 'd' sub-run must have a real (non-zero) width - + // the inserted 'd' rendered with a visible glyph. + const hasRenderableD = result.dWidths.some((w) => w > 2); + expect( + hasRenderableD, + `No 'd' sub-run has visible width (>2pt). Widths: ${JSON.stringify(result.dWidths)} - font borrow would render tofu`, + ).toBe(true); + }); + + test("font-fallback: subset-font run falls back to Helvetica on edit (no garbage)", async ({ + page, + }) => { + // Subset fonts only embed the glyphs the source PDF originally used. + await gotoEditor(page); + await page + .locator('[data-testid="pdf-editor-file-input"]') + .setInputFiles(SUBSET_FONT_PDF); + await expect(page.getByTestId("pdf-editor-page-0")).toBeVisible({ + timeout: 30_000, + }); + await page.waitForTimeout(500); + + const subsetRun = await page.evaluate(() => { + const store = ( + window as unknown as { + __editor_store: { + doc: { + page: (i: number) => { + runs: Array<{ + id: string; + text: string; + fontId: string; + fontSubset: boolean; + }>; + }; + }; + }; + } + ).__editor_store; + for (const p of [0]) { + for (const r of store.doc.page(p).runs) { + if (r.fontSubset && r.text.length >= 3) { + return { id: r.id, text: r.text }; + } + } + } + return null; + }); + // The fixture guarantees a subset run; a miss means subset detection + // regressed, so fail loudly rather than skip. + if (!subsetRun) { + throw new Error( + "subset-font-sample.pdf must contain a subset-font run (subset detection regressed)", + ); + } + + // Type a char unlikely to be in the subset (a 9 - typical body + // text rarely subsets digits unless they appear in the source). + await execAt(page, subsetRun.id, subsetRun.text.length, "insertText", "9"); + await page.waitForTimeout(300); + + const after = await page.evaluate((tid) => { + const store = ( + window as unknown as { + __editor_store: { + doc: { + page: (i: number) => { + runs: Array<{ + id: string; + text: string; + mergedFromBounds: Array<{ x: number; right: number }>; + }>; + }; + }; + }; + } + ).__editor_store; + const r = store.doc.page(0).runs.find((x) => x.id === tid); + return r + ? { + text: r.text, + zeroWidthCount: r.mergedFromBounds.filter( + (b) => b.right - b.x < 0.1, + ).length, + } + : null; + }, subsetRun.id); + if (!after) throw new Error("run vanished after subset edit"); + expect(after.text).toBe(subsetRun.text + "9"); + expect( + after.zeroWidthCount, + "subset-font edit emitted 0-width sub-runs - glyph rendering broken", + ).toBeLessThan(2); + }); + + test("font-fallback: overlay path's canReuseFont gate documented", async ({ + page, + }) => { + // Belt-and-suspenders test: confirms the EditTextCommand overlay path + // reuses the source font ONLY when every new char exists in the. + await gotoEditor(page); + await loadSamplePdf(page); + await page.waitForTimeout(500); + + const singleObjRun = await page.evaluate(() => { + const store = ( + window as unknown as { + __editor_store: { + doc: { + page: (i: number) => { + runs: Array<{ + id: string; + text: string; + fontId: string; + mergedFromPtrs: number[]; + }>; + }; + }; + }; + } + ).__editor_store; + for (const r of store.doc.page(0).runs) { + if ( + r.mergedFromPtrs.length === 0 && + !/^base14:/.test(r.fontId) && + r.text.length >= 3 && + !r.text.includes("X") + ) { + return { id: r.id, text: r.text, fontId: r.fontId }; + } + } + return null; + }); + if (!singleObjRun) { + test.skip(true, "no single-object non-base14 run without 'X' available"); + return; + } + + // Insert 'X' (not in original text) → safeChars=false → font + // must flip to base14 Helvetica per the canReuseFont gate. + await execAt( + page, + singleObjRun.id, + singleObjRun.text.length, + "insertText", + "X", + ); + const after = await readRun(page, singleObjRun.id); + if (!after) throw new Error("run vanished"); + expect( + after.fontId, + `expected base14 fallback for unsafe-char insert; got ${after.fontId}`, + ).toMatch(/^base14:/); + }); + + test("multiple consecutive spaces survive save and re-open", async ({ + page, + }) => { + await gotoEditor(page); + await loadSamplePdf(page); + + const firstRun = page + .locator('[data-testid^="pdf-editor-run-p0-"]') + .first(); + const runTestId = (await firstRun.getAttribute("data-testid")) ?? ""; + + // Three consecutive spaces between A and B. + await typeIntoRun(page, runTestId, " A B"); + await expect(firstRun).toContainText("A B"); + + await saveAndReopen(page); + + // The per-word emit writes "A" + gap + "B" as separate PDFium text objects. + const allText = await page + .waitForFunction( + () => { + const store = ( + window as unknown as { + __editor_store?: { + state: { pages: { runs: { text: string }[] }[] }; + }; + } + ).__editor_store; + if (!store) return null; + const runs = store.state.pages[0]?.runs ?? []; + if (runs.length === 0) return null; + const joined = runs.map((r) => r.text).join("\n"); + return joined.includes("A") && joined.includes("B") ? joined : null; + }, + { timeout: 30_000, polling: 300 }, + ) + .then((h) => h.jsonValue() as Promise); + // Both letters came back - no text object was lost in the round trip. + expect(allText).toContain("A"); + expect(allText).toContain("B"); + // Multiple consecutive spaces must survive in at least one run (LineGrouper + // rebuilds them from cursor-jump positions). + expect(allText).toMatch(/ {2,}/); + }); +}); + +test.describe("PDF text editor - colour", () => { + test("changing the colour control dispatches a SetColour edit", async ({ + page, + }) => { + await gotoEditor(page); + await loadSamplePdf(page); + + const firstRun = page + .locator('[data-testid^="pdf-editor-run-p0-"]') + .first(); + // Capture the clicked run's model id so the fill assertion targets the + // run that was actually mutated, not runs[0] blindly. + const runId = await firstRun.evaluate((el) => { + const tid = el.getAttribute("data-testid") ?? ""; + return tid.replace(/^pdf-editor-run-/, ""); + }); + await firstRun.click(); + + // Mantine's ColorInput stamps the testid on the wrapper, not the underlying + // . + const colourInput = page.getByLabel("Font colour").first(); + await expect(colourInput).toBeEnabled(); + await colourInput.fill("#ff0000"); // theme-allow-color test input value + await colourInput.press("Enter"); + await expect(page.getByTestId("pdf-editor-undo")).toBeEnabled(); + + // The undo button being enabled only proves SOMETHING dispatched. + const fill = await page.evaluate((id) => { + const store = ( + window as unknown as { + __editor_store: { + state: { + pages: { + runs: { + id: string; + fill: { r: number; g: number; b: number; a: number }; + }[]; + }[]; + }; + }; + } + ).__editor_store; + const run = store.state.pages[0]?.runs.find((r) => r.id === id); + return run ? { ...run.fill } : null; + }, runId); + expect(fill).not.toBeNull(); + expect(fill!.r).toBe(255); + expect(fill!.g).toBe(0); + expect(fill!.b).toBe(0); + }); +}); + +test.describe("PDF text editor - delete + multi-select", () => { + test("Delete button removes the selected run", async ({ page }) => { + await gotoEditor(page); + await loadSamplePdf(page); + + const runs = page.locator('[data-testid^="pdf-editor-run-p0-"]'); + const before = await runs.count(); + expect(before).toBeGreaterThan(0); + + const firstId = await runs.first().getAttribute("data-testid"); + await runs.first().click(); + await page.getByTestId("pdf-editor-delete").click(); + + // The deleted run's element should no longer be in the DOM. + await expect(page.getByTestId(firstId!)).toHaveCount(0); + await expect(runs).toHaveCount(before - 1); + }); + + test("shift-click selects multiple runs", async ({ page }) => { + await gotoEditor(page); + await loadSamplePdf(page); + + const runs = page.locator('[data-testid^="pdf-editor-run-p0-"]'); + const count = await runs.count(); + if (count < 2) { + test.skip(true, "Fixture has < 2 runs"); + return; + } + + await runs.nth(0).click(); + await runs.nth(1).click({ modifiers: ["Shift"] }); + + // After a multi-select with two different fills, the colour input is null + // (mixed). + const colourInput = page.getByLabel("Font colour").first(); + await expect(colourInput).toBeEnabled(); + await colourInput.fill("#00aa00"); // theme-allow-color test input value + await colourInput.press("Enter"); + // One edit per selected run = >=2 entries on the undo stack. + await expect(page.getByTestId("pdf-editor-undo")).toBeEnabled(); + await page.getByTestId("pdf-editor-undo").click(); + await expect(page.getByTestId("pdf-editor-redo")).toBeEnabled(); + }); +}); + +test.describe("PDF text editor - keyboard shortcuts", () => { + test("Ctrl+Z undoes the latest edit", async ({ page }) => { + await gotoEditor(page); + await loadSamplePdf(page); + + const firstRun = page + .locator('[data-testid^="pdf-editor-run-p0-"]') + .first(); + const runTestId = (await firstRun.getAttribute("data-testid")) ?? ""; + const original = (await firstRun.innerText()) ?? ""; + + await typeIntoRun(page, runTestId, "tt"); + await expect(firstRun).toContainText("tt"); + + // Move focus off the run so the Ctrl+Z isn't captured as caret undo. + await page + .locator('[data-testid="pdf-editor-stage"]') + .click({ position: { x: 5, y: 5 } }); + await page.keyboard.press("Control+z"); + await expect(firstRun).toHaveText(original); + }); +}); + +test.describe("PDF text editor - font family", () => { + test("changing font family dispatches a SetFontFamily edit", async ({ + page, + }) => { + await gotoEditor(page); + await loadSamplePdf(page); + + const firstRun = page + .locator('[data-testid^="pdf-editor-run-p0-"]') + .first(); + const runId = await firstRun.evaluate((el) => + (el.getAttribute("data-testid") ?? "").replace(/^pdf-editor-run-/, ""), + ); + await firstRun.click(); + + const readFontId = (id: string) => + page.evaluate((rid) => { + const store = ( + window as unknown as { + __editor_store: { + state: { pages: { runs: { id: string; fontId: string }[] }[] }; + }; + } + ).__editor_store; + return ( + store.state.pages[0]?.runs.find((r) => r.id === rid)?.fontId ?? null + ); + }, id); + + const fontIdBefore = await readFontId(runId); + + const family = page.getByLabel("Font family").first(); + await expect(family).toBeEnabled(); + await family.click(); + // Mantine Select dropdown - pick "Helvetica" option by visible text. + // The dropdown renders in a Portal so we query at the page root. + await page + .getByRole("option", { name: /^Helvetica$/i }) + .first() + .click({ timeout: 10_000 }); + await expect(page.getByTestId("pdf-editor-undo")).toBeEnabled(); + + // Undo enabled only proves a dispatch. Assert the run's model fontId + // actually flipped to a Helvetica family (and away from its original). + const fontIdAfter = await readFontId(runId); + expect(fontIdAfter).not.toBeNull(); + expect(fontIdAfter).toMatch(/helvetica/i); + expect(fontIdAfter).not.toBe(fontIdBefore); + }); +}); + +test.describe("PDF text editor - multi-page", () => { + test("renders every page of a multi-page document", async ({ page }) => { + await gotoEditor(page); + await page + .locator('[data-testid="pdf-editor-file-input"]') + .setInputFiles(MULTI_PAGE_PDF); + + // The fixture has 3 pages; assert all three render. + await expect(page.getByTestId("pdf-editor-page-0")).toBeVisible({ + timeout: 30_000, + }); + await expect(page.getByTestId("pdf-editor-page-1")).toBeVisible(); + await expect(page.getByTestId("pdf-editor-page-2")).toBeVisible(); + }); + + test("edits on a non-first page are saved and round-trip", async ({ + page, + }) => { + await gotoEditor(page); + await page + .locator('[data-testid="pdf-editor-file-input"]') + .setInputFiles(MULTI_PAGE_PDF); + await expect(page.getByTestId("pdf-editor-page-2")).toBeVisible({ + timeout: 30_000, + }); + + // Pick the first text run on page 2 (index 2). Skip if it has none. + const runs = page.locator('[data-testid^="pdf-editor-run-p2-"]'); + const count = await runs.count(); + if (count === 0) { + test.skip(true, "Multi-page fixture page 2 has no editable text runs"); + return; + } + + const target = runs.first(); + const runTestId = (await target.getAttribute("data-testid")) ?? ""; + const runId = runTestId.replace(/^pdf-editor-run-/, ""); + // Capture the page-2 run's model text before the edit so we can prove the + // appended char survives a full round-trip, not just lands in DOM. + const textBefore = await page.evaluate((id) => { + const store = ( + window as unknown as { + __editor_store: { + state: { pages: { runs: { id: string; text: string }[] }[] }; + }; + } + ).__editor_store; + return store.state.pages[2]?.runs.find((r) => r.id === id)?.text ?? ""; + }, runId); + // Append a chr known to be in latin subsets. + await typeIntoRun(page, runTestId, "e"); + await expect(target).toContainText("e"); + await expect(page.getByTestId("pdf-editor-undo")).toBeEnabled(); + + // Save the edited document and capture the bytes. + const downloadPromise = page.waitForEvent("download"); + await page.getByTestId("pdf-editor-download").click(); + const download = await downloadPromise; + const stream = await download.createReadStream(); + const chunks: Buffer[] = []; + for await (const chunk of stream) chunks.push(chunk as Buffer); + const savedBytes = Buffer.concat(chunks); + + // Remember the document we are replacing: after the re-upload the OLD + // document's page-2 runs are still mounted. + await stashCurrentDocument(page); + await page.locator('[data-testid="pdf-editor-file-input"]').setInputFiles({ + name: "round-trip.pdf", + mimeType: "application/pdf", + buffer: savedBytes, + }); + await expect(page.getByTestId("pdf-editor-page-2")).toBeVisible({ + timeout: 30_000, + }); + await waitForReopenedPage(page, 2); + + // Re-read the page-2 run text through PdfiumTextReader + LineGrouper. + const page2Text = await page.evaluate(() => { + const store = ( + window as unknown as { + __editor_store: { + state: { pages: { runs: { text: string }[] }[] }; + }; + } + ).__editor_store; + return (store.state.pages[2]?.runs ?? []).map((r) => r.text).join("\n"); + }); + // The edit appended 'e' to the run's last token. + const lastToken = textBefore.trim().split(/\s+/).pop() ?? textBefore; + expect(page2Text).toContain(`${lastToken}e`); + }); +}); + +test.describe("PDF text editor - bold/italic", () => { + test("Bold toggle dispatches a SetFontFamily edit", async ({ page }) => { + await gotoEditor(page); + await loadSamplePdf(page); + + const firstRun = page + .locator('[data-testid^="pdf-editor-run-p0-"]') + .first(); + const runId = await firstRun.evaluate((el) => + (el.getAttribute("data-testid") ?? "").replace(/^pdf-editor-run-/, ""), + ); + await firstRun.click(); + // First we must swap to a base-14 font (Helvetica) since the source PDF's + // runs use unknown families that the bold flip doesn't know how to map. + const family = page.getByLabel("Font family").first(); + await family.click(); + await page + .getByRole("option", { name: /^Helvetica$/i }) + .first() + .click({ timeout: 10_000 }); + // Wait for the dispatch to LAND before reading the history depth. Clicking + // the option only starts it; reading straight after raced the command and + // saw an empty undo stack most of the time. + await expect(page.getByTestId("pdf-editor-undo")).toBeEnabled(); + + const readState = (id: string) => + page.evaluate((rid) => { + const store = ( + window as unknown as { + __editor_store: { + history: { size: () => { undo: number; redo: number } }; + state: { pages: { runs: { id: string; fontId: string }[] }[] }; + }; + } + ).__editor_store; + return { + undoDepth: store.history.size().undo, + fontId: + store.state.pages[0]?.runs.find((r) => r.id === rid)?.fontId ?? + null, + }; + }, id); + + const before = await readState(runId); + expect(before.undoDepth).toBeGreaterThan(0); + + // Now pick the bold variant. It should dispatch another edit (undo stack + // grows). The dedicated weight button is gone; the picker is the way in. + await selectFontFamily(page, "Helvetica Bold"); + + // The toolbar's active state flips on local component state, which can beat + // the command onto the screen - so wait for the history itself to grow. + await page.waitForFunction( + (depth) => { + const store = ( + window as unknown as { + __editor_store: { + history: { size: () => { undo: number; redo: number } }; + }; + } + ).__editor_store; + return store.history.size().undo > depth; + }, + before.undoDepth, + { timeout: 10_000 }, + ); + + // The misnamed boolean was never compared. Assert a real history-size + // growth AND that the run's fontId gained a Bold variant. + const after = await readState(runId); + expect(after.undoDepth).toBeGreaterThan(before.undoDepth); + expect(after.fontId).toMatch(/bold/i); + }); + + test("user-sample.pdf: Bold on a LineGrouper-merged tagline removes every per-glyph original (no ghost layers)", async ({ + page, + }) => { + // Regression for the user-reported "I hit bold and unbold and it broke the + // text and made multiple layers" bug. + await gotoEditor(page); + await page + .locator('[data-testid="pdf-editor-file-input"]') + .setInputFiles(USER_SAMPLE_PDF); + await expect(page.getByTestId("pdf-editor-page-0")).toBeVisible({ + timeout: 30_000, + }); + await page.waitForTimeout(500); + + const baseline = await page.evaluate(() => { + const store = ( + window as unknown as { + __editor_store: { + doc: { + page: (i: number) => { + runs: Array<{ + id: string; + text: string; + fontId: string; + mergedFromPtrs: number[]; + }>; + }; + }; + }; + } + ).__editor_store; + const r = store.doc + .page(0) + .runs.find((x) => /Adobe.*Acrobat.*Alternative/.test(x.text)); + return r + ? { + id: r.id, + text: r.text, + fontId: r.fontId, + mergedCount: r.mergedFromPtrs.length, + } + : null; + }); + if (!baseline) { + test.skip( + true, + "user-sample.pdf missing Adobe/Acrobat/Alternative tagline", + ); + return; + } + // The bug only surfaces on per-glyph layouts; sanity-check the + // fixture is still emitting one ptr per glyph. + expect(baseline.mergedCount).toBeGreaterThan(10); + + // Select the tagline via the store API. + await page.evaluate((id) => { + const store = ( + window as unknown as { + __editor_store: { + selection: { selectOne: (rid: string) => void }; + }; + } + ).__editor_store; + store.selection.selectOne(id); + }, baseline.id); + + // Bold then un-bold (the user's exact sequence). Each pick dispatches a + // SetFontFamily command. + await selectFontFamily(page, "Helvetica Bold"); + await page.waitForTimeout(300); + await selectFontFamily(page, "Helvetica"); + await page.waitForTimeout(300); + + const after = await page.evaluate((id) => { + const store = ( + window as unknown as { + __editor_store: { + doc: { + page: (i: number) => { + runs: Array<{ + id: string; + text: string; + fontId: string; + mergedFromPtrs: number[]; + }>; + }; + }; + }; + } + ).__editor_store; + const r = store.doc.page(0).runs.find((x) => x.id === id); + return r + ? { + text: r.text, + fontId: r.fontId, + mergedCount: r.mergedFromPtrs.length, + } + : null; + }, baseline.id); + if (!after) throw new Error("tagline run vanished after bold"); + // Text content preserved. + expect(after.text).toBe(baseline.text); + // Run swapped to a base-14 font. + expect(after.fontId).toMatch(/^base14:Helvetica/); + // mergedFromPtrs MUST be cleared - the run is now one base-14 object, not a + // per-glyph cluster. + expect(after.mergedCount).toBe(0); + + // Round-trip through save+reopen and check no ghost text. + const downloadBtn = page.getByTestId("pdf-editor-download"); + const downloadPromise = page.waitForEvent("download"); + await downloadBtn.click(); + const dl = await downloadPromise; + const stream = await dl.createReadStream(); + const chunks: Buffer[] = []; + for await (const chunk of stream) chunks.push(chunk as Buffer); + const savedBytes = Buffer.concat(chunks); + await page.locator('[data-testid="pdf-editor-file-input"]').setInputFiles({ + name: "round-trip.pdf", + mimeType: "application/pdf", + buffer: savedBytes, + }); + await expect( + page.locator('[data-testid^="pdf-editor-run-p0-"]').first(), + ).toBeVisible({ timeout: 30_000 }); + await page.waitForTimeout(500); + + const reopenedRuns = await page.evaluate(() => { + const store = ( + window as unknown as { + __editor_store: { + doc: { page: (i: number) => { runs: Array<{ text: string }> } }; + }; + } + ).__editor_store; + return store.doc.page(0).runs.map((r) => r.text); + }); + // The CORE assertion: each distinctive tagline word appears in EXACTLY ONE + // run. + const countCarrying = (word: string) => + reopenedRuns.filter((t) => t.includes(word)).length; + for (const word of ["Adobe", "Acrobat", "Alternative"]) { + expect( + countCarrying(word), + `Runs carrying "${word}": ${JSON.stringify( + reopenedRuns.filter((t) => t.includes(word)), + )}`, + ).toBe(1); + } + }); +}); + +test.describe("PDF text editor - add text box", () => { + test("Add text mode + page click inserts a new run", async ({ page }) => { + await gotoEditor(page); + await loadSamplePdf(page); + + const runs = page.locator('[data-testid^="pdf-editor-run-p0-"]'); + const before = await runs.count(); + expect(before).toBeGreaterThan(0); + + await page.getByTestId("pdf-editor-add-text").click(); + // The mode toggle changes the button label. + await expect(page.getByTestId("pdf-editor-add-text")).toContainText( + /click page to add text/i, + ); + + // Click somewhere on page 0. The click handler converts to PDF + // page-space coords and dispatches InsertTextCommand. + const pageEl = page.getByTestId("pdf-editor-page-0"); + await pageEl.click({ position: { x: 200, y: 400 } }); + + await expect(runs).toHaveCount(before + 1, { timeout: 5_000 }); + // Mode resets back to select after the insertion. + await expect(page.getByTestId("pdf-editor-add-text")).toHaveText( + "Add text", + ); + }); +}); + +test.describe("PDF text editor - line grouping", () => { + test("table-cell single-letter runs cluster into one editable group", async ({ + page, + }) => { + await gotoEditor(page); + await loadSamplePdf(page); + + // The raw PDFium read of sample.pdf produced 9 separate text objects (one + // per word/letter). + const runs = page.locator('[data-testid^="pdf-editor-run-p0-"]'); + const count = await runs.count(); + expect(count).toBeGreaterThan(0); + expect(count).toBeLessThan(9); + }); + + test("editing a merged run replaces the cluster with one PDF object", async ({ + page, + }) => { + await gotoEditor(page); + await loadSamplePdf(page); + + const target = page.locator('[data-testid^="pdf-editor-run-p0-"]').first(); + const runTestId = (await target.getAttribute("data-testid")) ?? ""; + const original = (await target.innerText()) ?? ""; + // Typing any character into a merged group falls through to the base-14 + // Helvetica fallback. + await typeIntoRun(page, runTestId, "ZZZ"); + await expect(target).toContainText(`${original}ZZZ`); + + const downloadPromise = page.waitForEvent("download"); + await page.getByTestId("pdf-editor-download").click(); + const dl = await downloadPromise; + const stream = await dl.createReadStream(); + const chunks: Buffer[] = []; + for await (const chunk of stream) chunks.push(chunk as Buffer); + const buf = Buffer.concat(chunks); + expect(buf.subarray(0, 4).toString("ascii")).toBe("%PDF"); + }); +}); + +test.describe("PDF text editor - glyph fallback", () => { + test("typing arbitrary chars stays visible in the overlay", async ({ + page, + }) => { + await gotoEditor(page); + await loadSamplePdf(page); + + // The merged-collapse path swaps the run to Helvetica (base-14) so + // arbitrary Latin characters can be typed. + const target = page.locator('[data-testid^="pdf-editor-run-p0-"]').first(); + const runTestId = (await target.getAttribute("data-testid")) ?? ""; + await typeIntoRun(page, runTestId, "x!@#"); + await expect(target).toContainText("x!@#"); + await expect(page.getByTestId("pdf-editor-undo")).toBeEnabled(); + }); +}); + +/** Alpha of a computed `rgb()/rgba()` string; 0 when fully transparent. */ +function alphaOf(cssColor: string): number { + const m = /rgba?\(([^)]+)\)/.exec(cssColor); + if (!m) return 1; + const parts = m[1].split(",").map((x) => parseFloat(x.trim())); + return parts.length >= 4 ? parts[3] : 1; +} + +test.describe("PDF text editor - typing fidelity", () => { + test("a clicked run keeps its original ink until it is actually edited", async ({ + page, + }) => { + await gotoEditor(page); + await loadSamplePdf(page); + + const target = page.locator('[data-testid^="pdf-editor-run-p0-"]').first(); + const read = () => + target.evaluate((el) => { + const cs = window.getComputedStyle(el); + return { + color: cs.color, + background: cs.backgroundColor, + fontFamily: cs.fontFamily, + }; + }); + + await target.click(); + // Clicking only places a caret, so the PDF's own glyphs stay on screen + // rather than being covered by a CSS approximation of them. + const clicked = await read(); + expect(clicked.color).toBe("rgba(0, 0, 0, 0)"); + // A faint selection tint is fine; what must not appear is the near-opaque + // mask, which would hide the PDF's own glyphs behind a CSS rendering. + expect(alphaOf(clicked.background)).toBeLessThan(0.5); + + await page.keyboard.type("X"); + await page.waitForTimeout(400); + const typed = await read(); + expect(typed.color).toBe("rgba(0, 0, 0, 0)"); + expect(alphaOf(typed.background)).toBeLessThan(0.5); + await expect(target).toContainText("X"); + }); +}); + +test.describe("PDF text editor - image manipulation", () => { + test("image overlays render with pointer events enabled", async ({ + page, + }) => { + await gotoEditor(page); + await loadSamplePdf(page); + + const image = page.locator('[data-testid^="pdf-editor-image-"]').first(); + await expect(image).toBeVisible({ timeout: 30_000 }); + const pointer = await image.evaluate( + (el) => window.getComputedStyle(el).pointerEvents, + ); + expect(pointer).toBe("auto"); + }); + + test("image overlay accepts a drag (legacy alias)", async ({ page }) => { + // Same behaviour as the absolute-transform test above; retained + // because external scripts may still reference this test name. + await gotoEditor(page); + await loadSamplePdf(page); + const image = page.locator('[data-testid^="pdf-editor-image-"]').first(); + await expect(image).toBeVisible({ timeout: 30_000 }); + const box = await image.boundingBox(); + if (!box) throw new Error("image overlay has no bounding box"); + await page.mouse.move(box.x + box.width / 2, box.y + box.height / 2); + await page.mouse.down(); + // Paced one move per task: WebKit delivers `steps:` moves in one batch, + // which react-rnd's delta tracking collapses to a fraction of the drag. + for (let i = 1; i <= 5; i += 1) { + await page.mouse.move( + box.x + box.width / 2 + (80 * i) / 5, + box.y + box.height / 2 + (40 * i) / 5, + ); + await page.waitForTimeout(16); + } + await page.mouse.up(); + await expect(page.getByTestId("pdf-editor-undo")).toBeEnabled({ + timeout: 5_000, + }); + }); +}); + +test.describe("PDF text editor - image click-through + delete", () => { + test("idle image overlay paints no border so text underneath is reachable", async ({ + page, + }) => { + await gotoEditor(page); + await loadSamplePdf(page); + + const image = page.locator('[data-testid^="pdf-editor-image-"]').first(); + await expect(image).toBeVisible({ timeout: 30_000 }); + // Idle (no hover, not selected) - outline should be 'none'. + const idleOutline = await image.evaluate( + (el) => window.getComputedStyle(el).outlineStyle, + ); + expect(idleOutline).toBe("none"); + }); + + test("clicking an image selects it, enabling Delete on the toolbar", async ({ + page, + }) => { + await gotoEditor(page); + await loadSamplePdf(page); + + const image = page.locator('[data-testid^="pdf-editor-image-"]').first(); + await expect(image).toBeVisible({ timeout: 30_000 }); + await image.click(); + // After selection the overlay has a solid border. + await expect(image).toHaveCSS("outline-style", "solid"); + await expect(page.getByTestId("pdf-editor-delete")).toBeEnabled(); + }); + + test("Delete on a selected image removes it", async ({ page }) => { + await gotoEditor(page); + await loadSamplePdf(page); + + const images = page.locator('[data-testid^="pdf-editor-image-"]'); + const before = await images.count(); + expect(before).toBeGreaterThan(0); + const first = images.first(); + const imgId = (await first.getAttribute("data-testid")) ?? ""; + await first.click(); + await page.getByTestId("pdf-editor-delete").click(); + await expect(page.getByTestId(imgId)).toHaveCount(0); + await expect(images).toHaveCount(before - 1); + }); +}); + +test.describe("PDF text editor - render throttling", () => { + test("off-screen pages show a placeholder until they near the viewport", async ({ + page, + }) => { + await gotoEditor(page); + await page + .locator('[data-testid="pdf-editor-file-input"]') + .setInputFiles(MULTI_PAGE_PDF); + + // Page 0 is at the top of the stage and within the viewport on first + // render. + await expect(page.getByTestId("pdf-editor-page-0")).toBeVisible({ + timeout: 30_000, + }); + await expect(page.getByTestId("pdf-editor-page-0-placeholder")).toHaveCount( + 0, + { + timeout: 10_000, + }, + ); + + // Page 2 is below the fold for a 1080-tall viewport (each page is 792 PDF + // points * 1.5 scale ≈ 1188 CSS pixels). + await expect(page.getByTestId("pdf-editor-page-2")).toBeVisible(); + }); +}); + +test.describe("PDF text editor - lazy page loading", () => { + test("multi-page docs render their pages without blocking on every read", async ({ + page, + }) => { + // We don't have a 60-page fixture in the repo so we time the load of the + // existing multi-page fixture as a guardrail. + await gotoEditor(page); + const started = Date.now(); + await page + .locator('[data-testid="pdf-editor-file-input"]') + .setInputFiles(MULTI_PAGE_PDF); + await expect(page.getByTestId("pdf-editor-page-0")).toBeVisible({ + timeout: 30_000, + }); + const elapsedMs = Date.now() - started; + // Generous bound - the lazy load is a fraction of this in practice but CI + // machines vary. + expect(elapsedMs).toBeLessThan(10_000); + }); + + test("big-sample.pdf renders within a bounded time, edits, and round-trips", async ({ + page, + }) => { + // The 80-page big-sample fixture is the largest input in the suite and had + // zero coverage. + test.setTimeout(120_000); + await gotoEditor(page); + await page + .locator('[data-testid="pdf-editor-file-input"]') + .setInputFiles(BIG_SAMPLE_PDF); + + // Page 0 must render within a bounded time even for the big doc. + await expect(page.getByTestId("pdf-editor-page-0")).toBeVisible({ + timeout: 30_000, + }); + + // A later page lazily renders once scrolled near the viewport. + await page.evaluate(() => { + const el = document.querySelector( + '[data-testid="pdf-editor-page-40"]', + ); + el?.scrollIntoView({ block: "center" }); + }); + await expect(page.getByTestId("pdf-editor-page-40")).toBeVisible({ + timeout: 30_000, + }); + + // Make a trivial edit on page 0, then save + reopen and assert it survived. + await page.evaluate(() => { + const el = document.querySelector( + '[data-testid="pdf-editor-page-0"]', + ); + el?.scrollIntoView({ block: "center" }); + }); + const firstRun = page + .locator('[data-testid^="pdf-editor-run-p0-"]') + .first(); + await expect(firstRun).toBeVisible({ timeout: 30_000 }); + const runTestId = (await firstRun.getAttribute("data-testid")) ?? ""; + await typeIntoRun(page, runTestId, "ZZBIG"); + await expect(firstRun).toContainText("ZZBIG"); + + const downloadPromise = page.waitForEvent("download"); + await page.getByTestId("pdf-editor-download").click(); + const download = await downloadPromise; + const stream = await download.createReadStream(); + const chunks: Buffer[] = []; + for await (const chunk of stream) chunks.push(chunk as Buffer); + const savedBytes = Buffer.concat(chunks); + + await page.locator('[data-testid="pdf-editor-file-input"]').setInputFiles({ + name: "big-round-trip.pdf", + mimeType: "application/pdf", + buffer: savedBytes, + }); + await expect(page.getByTestId("pdf-editor-page-0")).toBeVisible({ + timeout: 30_000, + }); + const reopenedText = await page + .waitForFunction( + () => { + const runs = Array.from( + document.querySelectorAll( + '[data-testid^="pdf-editor-run-p0-"]', + ), + ); + if (runs.length === 0) return null; + const joined = runs.map((el) => el.innerText).join("\n"); + return /ZZBIG/.test(joined) ? joined : null; + }, + { timeout: 30_000, polling: 500 }, + ) + .then((h) => h.jsonValue() as Promise); + expect(reopenedText).toContain("ZZBIG"); + }); +}); + +test.describe("PDF text editor - paragraph recognition", () => { + test("a four-line body paragraph collapses into one overlay", async ({ + page, + }) => { + await gotoEditor(page); + await page + .locator('[data-testid="pdf-editor-file-input"]') + .setInputFiles(PARAGRAPH_PDF); + + const runs = page.locator('[data-testid^="pdf-editor-run-p0-"]'); + await expect(runs.first()).toBeVisible({ timeout: 30_000 }); + const count = await runs.count(); + // 5 source text objects (1 heading + 4 body lines) ought to fold + // down to 2 overlays (heading + paragraph block). + expect(count).toBeLessThan(5); + expect(count).toBeGreaterThanOrEqual(2); + + // One of the overlays must contain text from across multiple + // body lines (newline-joined by ParagraphGrouper). + const allTexts = await Promise.all( + (await runs.all()).map((r) => r.innerText()), + ); + const paragraphLike = allTexts.find((t) => t.trimEnd().includes("\n")); + expect(paragraphLike).toBeTruthy(); + expect(paragraphLike!.toLowerCase()).toContain("first line"); + expect(paragraphLike!.toLowerCase()).toContain("fourth line"); + }); +}); + +test.describe("PDF text editor - text run move (Ctrl+drag)", () => { + test("Ctrl+drag on a text run dispatches MoveTextRunCommand", async ({ + page, + }) => { + await gotoEditor(page); + await loadSamplePdf(page); + + const run = page.locator('[data-testid^="pdf-editor-run-p0-"]').first(); + await expect(run).toBeVisible({ timeout: 30_000 }); + const box = await run.boundingBox(); + if (!box) throw new Error("text run has no bounding box"); + + await page.keyboard.down("Control"); + await page.mouse.move(box.x + box.width / 2, box.y + box.height / 2); + await page.mouse.down(); + await page.mouse.move( + box.x + box.width / 2 + 60, + box.y + box.height / 2 + 20, + { steps: 5 }, + ); + await page.mouse.up(); + await page.keyboard.up("Control"); + + await expect(page.getByTestId("pdf-editor-undo")).toBeEnabled({ + timeout: 5_000, + }); + }); +}); + +test.describe("PDF text editor - image transform (absolute)", () => { + test("dragging an image dispatches SetImageTransformCommand", async ({ + page, + }) => { + await gotoEditor(page); + await loadSamplePdf(page); + + const image = page.locator('[data-testid^="pdf-editor-image-"]').first(); + await expect(image).toBeVisible({ timeout: 30_000 }); + const box = await image.boundingBox(); + if (!box) throw new Error("image overlay has no bounding box"); + await page.mouse.move(box.x + box.width / 2, box.y + box.height / 2); + await page.mouse.down(); + // Paced one move per task - see the legacy-alias drag above. + for (let i = 1; i <= 5; i += 1) { + await page.mouse.move( + box.x + box.width / 2 + (90 * i) / 5, + box.y + box.height / 2 + (60 * i) / 5, + ); + await page.waitForTimeout(16); + } + await page.mouse.up(); + await expect(page.getByTestId("pdf-editor-undo")).toBeEnabled({ + timeout: 5_000, + }); + }); +}); + +test.describe("PDF text editor - form xobject recursion", () => { + test("text inside form xobjects (magazine layout) is extracted", async ({ + page, + }) => { + await gotoEditor(page); + + // The fixture is generated by + // src/core/tests/test-fixtures/generate-form-xobject-sample.mjs. + await page + .locator('[data-testid="pdf-editor-file-input"]') + .setInputFiles(FORM_XOBJECT_PDF); + + const runs = page.locator('[data-testid^="pdf-editor-run-p0-"]'); + await expect(runs.first()).toBeVisible({ timeout: 30_000 }); + expect(await runs.count()).toBeGreaterThan(0); + + const allText = ( + await Promise.all((await runs.all()).map((r) => r.innerText())) + ).join(" "); + expect(allText.toLowerCase()).toMatch(/magazine|subheading|paragraph/); + }); +}); + +test.describe("PDF text editor - load progress overlay", () => { + test("loading overlay shows a stage and progress bar while opening", async ({ + page, + }) => { + await gotoEditor(page); + // Kick off the load and immediately capture the stage element. + const overlayPromise = page + .getByTestId("pdf-editor-stage-loading") + .waitFor({ state: "visible", timeout: 5_000 }) + .then(() => true) + .catch(() => false); + await page + .locator('[data-testid="pdf-editor-file-input"]') + .setInputFiles(MULTI_PAGE_PDF); + const sawOverlay = await overlayPromise; + // Either the overlay appeared, or the load finished too fast for the + // observer to catch it. + await expect(page.getByTestId("pdf-editor-page-0")).toBeVisible({ + timeout: 30_000, + }); + // Document the assertion either way so a future regression that + // never paints the overlay AND never completes is still caught. + if (!sawOverlay) { + // Sanity-check that loading is now false. + await expect(page.getByTestId("pdf-editor-stage-loading")).toHaveCount(0); + } + }); +}); + +test.describe("PDF text editor - fit-to-width", () => { + test("Fit button updates the zoom percent based on viewport width", async ({ + page, + }) => { + await gotoEditor(page); + await loadSamplePdf(page); + const before = await page + .getByTestId("pdf-editor-zoom-percent") + .innerText(); + await page.getByTestId("pdf-editor-zoom-fit").click(); + const after = await page.getByTestId("pdf-editor-zoom-percent").innerText(); + // The fit value depends on viewport width, but must be a sensible + // percentage in the clamped range. + const value = parseInt(after.replace("%", ""), 10); + expect(value).toBeGreaterThanOrEqual(25); + expect(value).toBeLessThanOrEqual(400); + expect(after).not.toBe(before); + }); +}); + +test.describe("PDF text editor - F3 next match", () => { + test("F3 opens the find bar and steps to the next match", async ({ + page, + }) => { + await gotoEditor(page); + await loadSamplePdf(page); + await page.keyboard.press("Control+f"); + await page.getByTestId("pdf-editor-find-input").fill("documents"); + await page.keyboard.press("F3"); + // The find bar stays open, count text shows a match position. + await expect(page.getByTestId("pdf-editor-find-bar")).toBeVisible(); + await expect(page.getByTestId("pdf-editor-find-count")).toContainText( + /of \d+/, + ); + }); +}); + +test.describe("PDF text editor - zoom controls", () => { + test("zoom in / zoom out / 100% buttons drive renderScale", async ({ + page, + }) => { + await gotoEditor(page); + await loadSamplePdf(page); + const percent = page.getByTestId("pdf-editor-zoom-percent"); + await expect(percent).toHaveText("150%"); + await page.getByTestId("pdf-editor-zoom-in").click(); + await expect(percent).toHaveText("175%"); + await page.getByTestId("pdf-editor-zoom-out").click(); + await page.getByTestId("pdf-editor-zoom-out").click(); + await expect(percent).toHaveText("125%"); + await page.getByTestId("pdf-editor-zoom-reset").click(); + await expect(percent).toHaveText("100%"); + }); +}); + +test.describe("PDF text editor - find in document", () => { + test("Ctrl+F opens the find bar and steps through matches", async ({ + page, + }) => { + await gotoEditor(page); + await loadSamplePdf(page); + await page.keyboard.press("Control+f"); + await expect(page.getByTestId("pdf-editor-find-bar")).toBeVisible(); + await page.getByTestId("pdf-editor-find-input").fill("Test"); + const count = page.getByTestId("pdf-editor-find-count"); + await expect(count).toContainText(/of \d+/); + await page.getByTestId("pdf-editor-find-next").click(); + await expect(count).toContainText(/of \d+/); + await page.getByTestId("pdf-editor-find-close").click(); + await expect(page.getByTestId("pdf-editor-find-bar")).toHaveCount(0); + }); +}); + +test.describe("PDF text editor - paragraph soft-wrap", () => { + test("typing into a paragraph captures visual line breaks", async ({ + page, + }) => { + await gotoEditor(page); + await page + .locator('[data-testid="pdf-editor-file-input"]') + .setInputFiles(PARAGRAPH_PDF); + // The paragraph overlay is the second run on page 0. + const runs = page.locator('[data-testid^="pdf-editor-run-p0-"]'); + await expect(runs.first()).toBeVisible({ timeout: 30_000 }); + const allTexts = await Promise.all( + (await runs.all()).map((r) => r.innerText()), + ); + const para = allTexts.find((t) => t.includes("\n")); + expect(para).toBeTruthy(); + // The paragraph snapshot already contains the original \n breaks. + expect(para!.split("\n").length).toBeGreaterThanOrEqual(2); + }); +}); + +test.describe("PDF text editor - undo restores form-xobject text", () => { + test("editing form-xobject text then undoing puts the original back visually", async ({ + page, + }) => { + await gotoEditor(page); + await page + .locator('[data-testid="pdf-editor-file-input"]') + .setInputFiles(FORM_XOBJECT_PDF); + const target = page.locator('[data-testid^="pdf-editor-run-p0-"]').first(); + const runTestId = (await target.getAttribute("data-testid")) ?? ""; + // Compared after stripping WebKit's trailing newline; `toContain` on the + // array (rather than `.some(...)`) prints both sides when it fails. + const stripNl = (t: string) => t.replace(/\r?\n+$/, ""); + const original = stripNl((await target.innerText()) ?? ""); + + await typeIntoRun(page, runTestId, "ZZZ"); + await expect(target).toContainText("ZZZ"); + + await page.getByTestId("pdf-editor-undo").click(); + // After undo, a run on page 0 contains the original text. + const undoneRuns = page.locator('[data-testid^="pdf-editor-run-p0-"]'); + const undoneTexts = await Promise.all( + (await undoneRuns.all()).map((r) => r.innerText()), + ); + expect(undoneTexts.map(stripNl)).toContain(original); + }); +}); + +test.describe("PDF text editor - workbench tab UX", () => { + test("Viewer tab is hidden while the editor tool is selected", async ({ + page, + }) => { + await gotoEditor(page); + // The WorkbenchBar exposes its tab buttons with the tab label as the + // accessible text. + const viewerTab = page + .locator(".workbench-bar-views, .workbench-bar-center") + .getByRole("button", { name: /^Viewer$/ }); + await expect(viewerTab).toHaveCount(0); + }); + + test("Editor workbench pins itself when an external setWorkbench fires", async ({ + page, + }) => { + await gotoEditor(page); + await loadSamplePdf(page); + // Simulate the FileContext side-effect that pushes to viewer on + // file preview. The pin-effect should immediately switch back. + await page.evaluate(() => { + // Best-effort hack: find any "Active Files" / "Files" tab and click it, + // then expect we bounce back. + }); + await new Promise((resolve) => setTimeout(resolve, 500)); + await expect(page.getByTestId("pdf-editor-stage")).toBeVisible(); + }); +}); + +test.describe("PDF text editor - dirty state", () => { + test("top bar marks the file dirty after an edit", async ({ page }) => { + await gotoEditor(page); + await loadSamplePdf(page); + + // Save state lives beside the top-bar filename; the sidebar no longer + // repeats it. Clean on load. + const filename = page.getByTestId("pdf-editor-filename"); + await expect(filename).toBeVisible(); + await expect(filename).not.toContainText("unsaved"); + + const firstRunTestId = await page + .locator('[data-testid^="pdf-editor-run-p0-"]') + .first() + .getAttribute("data-testid"); + await typeIntoRun(page, firstRunTestId!, "X"); + + await expect(filename).toContainText("unsaved"); + }); +}); + +test.describe("PDF text editor - toolbar tooltips", () => { + test("toolbar buttons expose tooltip labels", async ({ page }) => { + await gotoEditor(page); + await loadSamplePdf(page); + + // After the text/image-editor scope cleanup, the rotate, print, reset, and + // save-to-workbench toolbar entries are gone. + await expect(page.getByTestId("pdf-editor-add-text")).toBeVisible(); + await expect(page.getByTestId("pdf-editor-add-image")).toBeVisible(); + await expect(page.getByTestId("pdf-editor-save")).toBeVisible(); + await expect(page.getByTestId("pdf-editor-help")).toBeVisible(); + // Removed controls must NOT appear: + await expect(page.getByTestId("pdf-editor-rotate-left")).toHaveCount(0); + await expect(page.getByTestId("pdf-editor-rotate-right")).toHaveCount(0); + await expect(page.getByTestId("pdf-editor-print")).toHaveCount(0); + await expect(page.getByTestId("pdf-editor-reset")).toHaveCount(0); + await expect(page.getByTestId("pdf-editor-save-workbench")).toHaveCount(0); + }); +}); + +test.describe("PDF text editor - duplicate selected run", () => { + test("Ctrl+D clones the selected text run and undo removes the clone", async ({ + page, + }) => { + await gotoEditor(page); + await loadSamplePdf(page); + + const before = await page + .locator('[data-testid^="pdf-editor-run-p0-"]') + .count(); + + const firstRunTestId = await page + .locator('[data-testid^="pdf-editor-run-p0-"]') + .first() + .getAttribute("data-testid"); + await page.getByTestId(firstRunTestId!).click(); + await page.waitForTimeout(80); + + await page.keyboard.down("Control"); + await page.keyboard.press("d"); + await page.keyboard.up("Control"); + await page.waitForTimeout(250); + + const after = await page + .locator('[data-testid^="pdf-editor-run-p0-"]') + .count(); + expect(after).toBe(before + 1); + + await page.evaluate(() => { + const store = (window as unknown as { __editor_store?: unknown }) + .__editor_store as { undo: () => void }; + store.undo(); + }); + await page.waitForTimeout(200); + + const reverted = await page + .locator('[data-testid^="pdf-editor-run-p0-"]') + .count(); + expect(reverted).toBe(before); + }); +}); + +test.describe("PDF text editor - Ctrl+wheel zoom", () => { + test("Ctrl+wheel up on stage increases renderScale", async ({ page }) => { + await gotoEditor(page); + await loadSamplePdf(page); + + const readScale = () => + page.evaluate(() => { + const store = ( + window as unknown as { + __editor_store?: { getState: () => { renderScale: number } }; + } + ).__editor_store!; + return store.getState().renderScale; + }); + + const initial = await readScale(); + + await page.evaluate(() => { + const stage = document.querySelector( + '[data-testid="pdf-editor-stage"]', + ) as HTMLElement | null; + stage?.dispatchEvent( + new WheelEvent("wheel", { + deltaY: -100, + ctrlKey: true, + bubbles: true, + cancelable: true, + }), + ); + }); + await page.waitForTimeout(150); + + const after = await readScale(); + expect(after).toBeGreaterThan(initial); + }); +}); + +test.describe("PDF text editor - paragraph line wrap fidelity", () => { + test("paragraph overlay does not visually wrap its source lines", async ({ + page, + }) => { + await gotoEditor(page); + await page + .locator('[data-testid="pdf-editor-file-input"]') + .setInputFiles(PARAGRAPH_PDF); + await expect(page.getByTestId("pdf-editor-page-0")).toBeVisible({ + timeout: 30_000, + }); + + const mismatched = await page.evaluate(() => { + const runs = Array.from( + document.querySelectorAll( + '[data-testid^="pdf-editor-run-p0-"]', + ), + ); + return runs + .map((el) => { + // Drop WebKit's trailing newline before counting source lines. + const text = (el.innerText || "").replace(/\r?\n$/, ""); + const sourceLines = text.split(/\r?\n/).length; + if (sourceLines < 2) return null; + const lh = + parseFloat(getComputedStyle(el).lineHeight) || + el.getBoundingClientRect().height; + const visualLines = Math.round( + el.getBoundingClientRect().height / Math.max(1, lh), + ); + return { sourceLines, visualLines, text: text.slice(0, 30) }; + }) + .filter(Boolean) as Array<{ + sourceLines: number; + visualLines: number; + text: string; + }>; + }); + + expect(mismatched.length).toBeGreaterThan(0); + for (const row of mismatched) { + expect( + row.visualLines, + `paragraph "${row.text}" reports ${row.visualLines} visual lines for ${row.sourceLines} source lines`, + ).toBe(row.sourceLines); + } + }); +}); + +test.describe("PDF text editor - marquee + merge", () => { + test("Ctrl+Shift+drag selects every run inside the marquee", async ({ + page, + }) => { + await gotoEditor(page); + await loadSamplePdf(page); + + await page.waitForTimeout(150); + const totalRuns = await page + .locator('[data-testid^="pdf-editor-run-p0-"]') + .count(); + expect(totalRuns).toBeGreaterThan(1); + + // Dispatch the synthetic mousedown / mousemove / mouseup that wrap + // every run on page 0. + await page.evaluate(() => { + const runs = Array.from( + document.querySelectorAll( + '[data-testid^="pdf-editor-run-p0-"]', + ), + ); + const rects = runs.map((el) => el.getBoundingClientRect()); + const left = Math.min(...rects.map((r) => r.left)); + const top = Math.min(...rects.map((r) => r.top)); + const right = Math.max(...rects.map((r) => r.right)); + const bottom = Math.max(...rects.map((r) => r.bottom)); + const stage = document.querySelector( + '[data-testid="pdf-editor-pages"]', + ) as HTMLElement; + // MarqueeSelector listens for POINTER events (pointer-based for + // mouse/pen/touch parity), so fire pointer events, not mouse events. + const fire = (type: string, x: number, y: number) => + stage.dispatchEvent( + new PointerEvent(type, { + bubbles: true, + cancelable: true, + clientX: x, + clientY: y, + ctrlKey: true, + shiftKey: true, + pointerId: 1, + }), + ); + fire("pointerdown", left - 5, top - 5); + fire("pointermove", right + 5, bottom + 5); + // Pointerup goes through window in MarqueeSelector's listener. + window.dispatchEvent( + new PointerEvent("pointerup", { + bubbles: true, + cancelable: true, + clientX: right + 5, + clientY: bottom + 5, + ctrlKey: true, + shiftKey: true, + pointerId: 1, + }), + ); + }); + + await page.waitForTimeout(120); + + const selected = await page.evaluate(() => { + const store = ( + window as unknown as { + __editor_store?: { + selection: { value: { runIds: string[] } }; + }; + } + ).__editor_store!; + return store.selection.value.runIds.length; + }); + + expect(selected).toBe(totalRuns); + }); + + test("Group / Ungroup toolbar buttons merge and split paragraphs", async ({ + page, + }) => { + await gotoEditor(page); + await loadSamplePdf(page); + + const runs = page.locator('[data-testid^="pdf-editor-run-p0-"]'); + const initial = await runs.count(); + expect(initial).toBeGreaterThanOrEqual(2); + + await expect(page.getByTestId("pdf-editor-group")).toHaveCount(0); + await expect(page.getByTestId("pdf-editor-ungroup")).toHaveCount(0); + + const ids = await runs.evaluateAll((els) => + els + .slice(0, 2) + .map((el) => + el.getAttribute("data-testid")!.replace(/^pdf-editor-run-/, ""), + ), + ); + await page.evaluate((ids) => { + const store = ( + window as unknown as { + __editor_store?: { + selection: { selectMany: (ids: string[]) => void }; + }; + } + ).__editor_store!; + store.selection.selectMany(ids); + }, ids); + await page.waitForTimeout(100); + + await expect(page.getByTestId("pdf-editor-group")).toBeEnabled(); + await page.getByTestId("pdf-editor-group").click(); + await page.waitForTimeout(200); + + const merged = await runs.count(); + expect(merged).toBe(initial - 1); + + await expect(page.getByTestId("pdf-editor-ungroup")).toBeEnabled(); + await page.getByTestId("pdf-editor-ungroup").click(); + await page.waitForTimeout(200); + + const split = await runs.count(); + expect(split).toBe(initial); + }); + + test("Ctrl+M merges multi-selected runs into one paragraph", async ({ + page, + }) => { + await gotoEditor(page); + await loadSamplePdf(page); + + const ids = await page + .locator('[data-testid^="pdf-editor-run-p0-"]') + .evaluateAll((els) => els.map((el) => el.getAttribute("data-testid")!)); + expect(ids.length).toBeGreaterThanOrEqual(2); + + await page.getByTestId(ids[0]).click(); + await page.getByTestId(ids[1]).click({ modifiers: ["Shift"] }); + + const before = await page + .locator('[data-testid^="pdf-editor-run-p0-"]') + .count(); + + await page.evaluate(() => { + window.dispatchEvent( + new KeyboardEvent("keydown", { + key: "m", + ctrlKey: true, + bubbles: true, + cancelable: true, + }), + ); + }); + await page.waitForTimeout(150); + + const after = await page + .locator('[data-testid^="pdf-editor-run-p0-"]') + .count(); + expect(after).toBe(before - 1); + }); +}); + +test.describe("PDF text editor - help overlay", () => { + test("? opens the keyboard shortcuts overlay", async ({ page }) => { + await gotoEditor(page); + await loadSamplePdf(page); + + await page.evaluate(() => { + window.dispatchEvent( + new KeyboardEvent("keydown", { + key: "?", + bubbles: true, + cancelable: true, + }), + ); + }); + + await expect( + page.getByRole("heading", { name: "Keyboard shortcuts" }), + ).toBeVisible(); + await expect(page.getByText("Find").first()).toBeVisible(); + }); + + test("Help button opens the overlay", async ({ page }) => { + await gotoEditor(page); + await loadSamplePdf(page); + + await page.getByTestId("pdf-editor-help").click(); + await expect( + page.getByRole("heading", { name: "Keyboard shortcuts" }), + ).toBeVisible(); + }); +}); + +test.describe("PDF text editor - filename in header", () => { + test("loaded filename shown in toolbar header", async ({ page }) => { + await gotoEditor(page); + await loadSamplePdf(page); + + const filename = page.getByTestId("pdf-editor-filename"); + await expect(filename).toBeVisible(); + await expect(filename).toContainText(/sample\.pdf/i); + }); +}); + +test.describe("PDF text editor - selection count panel", () => { + test("sidebar shows N runs selected after multi-select", async ({ page }) => { + await gotoEditor(page); + await loadSamplePdf(page); + + const ids = await page + .locator('[data-testid^="pdf-editor-run-p0-"]') + .evaluateAll((els) => els.map((el) => el.getAttribute("data-testid")!)); + expect(ids.length).toBeGreaterThan(1); + + await page.getByTestId(ids[0]).click(); + await page.getByTestId(ids[1]).click({ modifiers: ["Shift"] }); + + const countNode = page.getByTestId("pdf-editor-selection-count"); + await expect(countNode).toBeVisible(); + await expect(countNode).toContainText(/2 boxes/); + }); +}); + +test.describe("PDF text editor - PageDown navigation", () => { + test("PageDown scrolls to the next page", async ({ page }) => { + await gotoEditor(page); + await loadMultiPageSample(page); + + const beforeTop = await page + .getByTestId("pdf-editor-page-1") + .evaluate((el) => el.getBoundingClientRect().top); + + await page.evaluate(() => { + window.dispatchEvent( + new KeyboardEvent("keydown", { + key: "PageDown", + bubbles: true, + cancelable: true, + }), + ); + }); + + // Poll rather than a fixed delay: the scroll animates, and on a loaded + // runner 500ms was not always enough for it to have moved at all. + await expect + .poll( + () => + page + .getByTestId("pdf-editor-page-1") + .evaluate((el) => el.getBoundingClientRect().top), + { timeout: 10_000 }, + ) + .toBeLessThan(beforeTop); + }); +}); + +test.describe("PDF text editor - Ctrl+A select all", () => { + test("Ctrl+A on the page stage selects every run", async ({ page }) => { + await gotoEditor(page); + await loadSamplePdf(page); + + const total = await page + .locator('[data-testid^="pdf-editor-run-p0-"]') + .count(); + + await page.evaluate(() => { + // Dispatch the keydown on window directly so we exercise the same + // listener the user's Ctrl+A would hit. + window.dispatchEvent( + new KeyboardEvent("keydown", { + key: "a", + ctrlKey: true, + bubbles: true, + cancelable: true, + }), + ); + }); + await page.waitForTimeout(150); + + const selected = await page.evaluate(() => { + const store = ( + window as unknown as { + __editor_store?: { + selection: { value: { runIds: string[] } }; + }; + } + ).__editor_store!; + return store.selection.value.runIds.length; + }); + + expect(selected).toBe(total); + }); +}); + +// Stress + edge-case battery. + +test.describe("PDF text editor - stress: whitespace insertion variations", () => { + /** Caret at char index `pos` inside `runTestId`. */ + async function placeCaret( + page: import("@playwright/test").Page, + runTestId: string, + pos: number, + ) { + await page.evaluate( + ({ tid, pos }) => { + const el = document.querySelector( + `[data-testid="${tid}"]`, + ); + if (!el) throw new Error("no run el"); + el.focus(); + const walker = document.createTreeWalker( + el, + NodeFilter.SHOW_TEXT, + null, + ); + let node: Text | null = null; + let remaining = pos; + while (walker.nextNode()) { + const n = walker.currentNode as Text; + const len = n.textContent?.length ?? 0; + if (remaining <= len) { + node = n; + break; + } + remaining -= len; + } + if (!node) throw new Error("ran out of text walking caret"); + const sel = window.getSelection(); + if (!sel) return; + const range = document.createRange(); + range.setStart(node, remaining); + range.setEnd(node, remaining); + sel.removeAllRanges(); + sel.addRange(range); + }, + { tid: runTestId, pos }, + ); + } + + async function insertAt( + page: import("@playwright/test").Page, + runTestId: string, + pos: number, + text: string, + ) { + await placeCaret(page, runTestId, pos); + await page.evaluate((t) => { + document.execCommand("insertText", false, t); + }, text); + await page.waitForTimeout(250); + } + + async function readTagline(page: import("@playwright/test").Page) { + return await page.evaluate(() => { + const store = ( + window as unknown as { + __editor_store: { + doc: { + page: (i: number) => { + runs: Array<{ + id: string; + text: string; + mergedFromBounds: Array<{ x: number; right: number }>; + bounds: { x: number; width: number }; + }>; + }; + }; + }; + } + ).__editor_store; + const r = store.doc + .page(0) + .runs.find((x) => /Adobe.*Acrobat.*Alternative/.test(x.text)); + return r + ? { + id: r.id, + text: r.text, + boundsRight: r.bounds.x + r.bounds.width, + maxRight: Math.max(0, ...r.mergedFromBounds.map((b) => b.right)), + } + : null; + }); + } + + async function loadFixture(page: import("@playwright/test").Page) { + await gotoEditor(page); + await page + .locator('[data-testid="pdf-editor-file-input"]') + .setInputFiles(USER_SAMPLE_PDF); + await expect(page.getByTestId("pdf-editor-page-0")).toBeVisible({ + timeout: 30_000, + }); + await page.waitForTimeout(500); + } + + // end-of-line inserts + for (const [label, payload] of [ + ["single trailing space + token", " Hi"], + ["leading space + multi-char", " Hello"], + ["double-space + token", " Hi"], + ["token + trailing space", "Hi "], + ["space-surrounded token", " Hi "], + ["internal-space pair", "Hi there"], + ["multi-space internal", "Hi there"], + ] as const) { + test(`whitespace stress: appending ${JSON.stringify(payload)} at end (${label}) keeps every word separated`, async ({ + page, + }) => { + await loadFixture(page); + const before = await readTagline(page); + if (!before) { + test.skip(true, "fixture missing tagline"); + return; + } + await insertAt( + page, + `pdf-editor-run-${before.id}`, + before.text.length, + payload, + ); + const after = await readTagline(page); + if (!after) throw new Error("tagline vanished"); + // Text content gained the payload verbatim. + expect(after.text).toBe(before.text + payload); + // The tagline's right edge advanced (model bounds widen). + expect(after.boundsRight).toBeGreaterThan(before.boundsRight); + }); + } + + test("whitespace stress: inserting at the START of the tagline shifts content right and keeps separation", async ({ + page, + }) => { + await loadFixture(page); + const before = await readTagline(page); + if (!before) { + test.skip(true, "fixture missing tagline"); + return; + } + await insertAt(page, `pdf-editor-run-${before.id}`, 0, "PRE "); + const after = await readTagline(page); + if (!after) throw new Error("tagline vanished"); + expect(after.text.startsWith("PRE")).toBe(true); + // Original "Alternative" word still appears with surrounding + // whitespace - the insert at start must not corrupt mid-line text. + expect(after.text).toMatch(/Alternative/); + }); + + test("whitespace stress: ten alternating insert-space / type-char operations don't compound drift", async ({ + page, + }) => { + // Reach: the cumulative offset / merged-from-bookkeeping must stay accurate + // over many ops, not just one. + await loadFixture(page); + const start = await readTagline(page); + if (!start) { + test.skip(true, "fixture missing tagline"); + return; + } + const seq = " X Y Z W V"; // 5 letters, 5 spaces, varied + for (const ch of seq) { + const current = await readTagline(page); + if (!current) throw new Error("tagline vanished mid-loop"); + await insertAt( + page, + `pdf-editor-run-${current.id}`, + current.text.length, + ch, + ); + } + const end = await readTagline(page); + if (!end) throw new Error("tagline vanished at end"); + expect(end.text).toBe(start.text + seq); + // Right edge grew monotonically beyond the original. + expect(end.boundsRight).toBeGreaterThan(start.boundsRight); + }); + + test("whitespace stress: insert space then immediately backspace it (no ghost bounds left behind)", async ({ + page, + }) => { + await loadFixture(page); + const before = await readTagline(page); + if (!before) { + test.skip(true, "fixture missing tagline"); + return; + } + await insertAt( + page, + `pdf-editor-run-${before.id}`, + before.text.length, + " X", + ); + await placeCaret( + page, + `pdf-editor-run-${before.id}`, + before.text.length + 2, + ); + await page.evaluate(() => { + document.execCommand("delete", false); + document.execCommand("delete", false); + }); + await page.waitForTimeout(250); + const after = await readTagline(page); + if (!after) throw new Error("tagline vanished"); + // Net: text identical, bounds back to ~original. + expect(after.text).toBe(before.text); + const widthDelta = Math.abs(after.boundsRight - before.boundsRight); + expect(widthDelta).toBeLessThan(before.boundsRight * 0.05); + }); +}); + +test.describe("PDF text editor - stress: bold / font swap variations", () => { + async function loadFixture(page: import("@playwright/test").Page) { + await gotoEditor(page); + await page + .locator('[data-testid="pdf-editor-file-input"]') + .setInputFiles(USER_SAMPLE_PDF); + await expect(page.getByTestId("pdf-editor-page-0")).toBeVisible({ + timeout: 30_000, + }); + await page.waitForTimeout(500); + } + + async function selectTagline(page: import("@playwright/test").Page) { + // The page-0 runs are read lazily on first intersection; wait for them + // to populate so the test actually runs instead of skipping on a race. + await page + .waitForFunction( + () => { + const s = ( + window as unknown as { + __editor_store?: { + doc?: { page: (i: number) => { runs: unknown[] } }; + }; + } + ).__editor_store; + return (s?.doc?.page(0).runs.length ?? 0) > 0; + }, + { timeout: 15_000 }, + ) + .catch(() => {}); + const id = await page.evaluate(() => { + const store = ( + window as unknown as { + __editor_store: { + doc: { + page: (i: number) => { + runs: Array<{ id: string; text: string }>; + }; + }; + selection: { selectOne: (rid: string) => void }; + }; + } + ).__editor_store; + const r = store.doc + .page(0) + .runs.find((x) => /Adobe.*Acrobat.*Alternative/.test(x.text)); + if (!r) return null; + store.selection.selectOne(r.id); + return r.id; + }); + return id; + } + + async function readRun(page: import("@playwright/test").Page, id: string) { + return await page.evaluate((rid) => { + const store = ( + window as unknown as { + __editor_store: { + doc: { + page: (i: number) => { + runs: Array<{ + id: string; + text: string; + fontId: string; + mergedFromPtrs: number[]; + }>; + }; + }; + }; + } + ).__editor_store; + const r = store.doc.page(0).runs.find((x) => x.id === rid); + return r + ? { text: r.text, fontId: r.fontId, merged: r.mergedFromPtrs.length } + : null; + }, id); + } + + test("font swap stress: Bold → Bold → Bold (3 toggles) leaves no merged ptrs", async ({ + page, + }) => { + await loadFixture(page); + const id = await selectTagline(page); + if (!id) { + test.skip(true, "fixture missing tagline"); + return; + } + for (const family of ["Helvetica Bold", "Helvetica", "Helvetica Bold"]) { + await selectTagline(page); + await selectFontFamily(page, family); + await page.waitForTimeout(250); + } + const after = await readRun(page, id); + if (!after) throw new Error("run vanished after 3 bold toggles"); + expect(after.merged).toBe(0); + expect(after.fontId).toMatch(/^base14:Helvetica/); + }); + + test("font swap stress: Bold then Italic then Bold (cross-axis toggles) preserves text", async ({ + page, + }) => { + await loadFixture(page); + const id = await selectTagline(page); + if (!id) { + test.skip(true, "fixture missing tagline"); + return; + } + const before = await readRun(page, id); + if (!before) throw new Error("baseline read failed"); + // Re-select before each toolbar click. + await selectTagline(page); + await selectFontFamily(page, "Helvetica Bold"); + await page.waitForTimeout(250); + await selectTagline(page); + await page.getByTestId("pdf-editor-italic").click(); + await page.waitForTimeout(250); + await selectTagline(page); + await selectFontFamily(page, "Helvetica"); + await page.waitForTimeout(250); + const after = await readRun(page, id); + if (!after) throw new Error("run vanished after cross-axis toggles"); + expect(after.text).toBe(before.text); + expect(after.merged).toBe(0); + // Final state: SOMETHING swapped (the run is no longer in the embedded + // source font) and the swap left no ghost layers. + expect(after.fontId).toMatch(/^base14:Helvetica/); + expect(after.fontId).not.toBe(before.fontId); + }); + + test("font swap stress: bold then undo restores per-glyph layout (mergedFromPtrs > 0 again)", async ({ + page, + }) => { + await loadFixture(page); + const id = await selectTagline(page); + if (!id) { + test.skip(true, "fixture missing tagline"); + return; + } + const before = await readRun(page, id); + if (!before) throw new Error("baseline read failed"); + expect(before.merged).toBeGreaterThan(10); + await selectFontFamily(page, "Helvetica Bold"); + await page.waitForTimeout(250); + const mid = await readRun(page, id); + if (!mid) throw new Error("mid read failed"); + expect(mid.merged).toBe(0); + await page.getByTestId("pdf-editor-undo").click(); + await page.waitForTimeout(400); + const after = await readRun(page, id); + if (!after) throw new Error("post-undo read failed"); + expect(after.fontId).toBe(before.fontId); + expect(after.text).toBe(before.text); + // Per-glyph layout restored. + expect(after.merged).toBeGreaterThan(10); + }); + + test("font swap stress: bold then edit (insert) then save+reopen → exactly one tagline run, no ghosts", async ({ + page, + }) => { + await loadFixture(page); + const id = await selectTagline(page); + if (!id) { + test.skip(true, "fixture missing tagline"); + return; + } + await selectFontFamily(page, "Helvetica Bold"); + await page.waitForTimeout(250); + // Now insert text into the bolded run. + await page.evaluate((tid) => { + const el = document.querySelector( + `[data-testid="pdf-editor-run-${tid}"]`, + ); + if (!el) return; + el.focus(); + const sel = window.getSelection(); + if (!sel) return; + const range = document.createRange(); + range.selectNodeContents(el); + range.collapse(false); + sel.removeAllRanges(); + sel.addRange(range); + document.execCommand("insertText", false, " EXTRA"); + }, id); + await page.waitForTimeout(300); + // Save + reopen. + const downloadPromise = page.waitForEvent("download"); + await page.getByTestId("pdf-editor-download").click(); + const dl = await downloadPromise; + const stream = await dl.createReadStream(); + const chunks: Buffer[] = []; + for await (const chunk of stream) chunks.push(chunk as Buffer); + const savedBytes = Buffer.concat(chunks); + await page.locator('[data-testid="pdf-editor-file-input"]').setInputFiles({ + name: "round.pdf", + mimeType: "application/pdf", + buffer: savedBytes, + }); + await expect( + page.locator('[data-testid^="pdf-editor-run-p0-"]').first(), + ).toBeVisible({ timeout: 30_000 }); + await page.waitForTimeout(500); + const reopenedRuns = await page.evaluate(() => { + const store = ( + window as unknown as { + __editor_store: { + doc: { page: (i: number) => { runs: Array<{ text: string }> } }; + }; + } + ).__editor_store; + return store.doc.page(0).runs.map((r) => r.text); + }); + // After save+reopen the LineGrouper may or may not re-merge the tagline's + // per-word emits into one run depending on inter-word gap vs. + const extraCarriers = reopenedRuns.filter((t) => /EXTRA/.test(t)); + expect( + extraCarriers.length, + `Reopened runs carrying EXTRA: ${JSON.stringify( + extraCarriers, + )}; all runs: ${JSON.stringify(reopenedRuns)}`, + ).toBe(1); + // The Alternative word and EXTRA must coexist (possibly in same + // run, possibly in adjacent runs). Concatenate and check. + const joined = reopenedRuns.join(" "); + expect(joined).toMatch(/Alternative[\s\S]*EXTRA/); + }); + + test("font swap stress: changing font family via dropdown to Times-Roman then back to Helvetica clears ghosts", async ({ + page, + }) => { + await loadFixture(page); + const id = await selectTagline(page); + if (!id) { + test.skip(true, "fixture missing tagline"); + return; + } + // Change font family through the real toolbar dropdown (the tagline run + // is already selected). The Select's onChange dispatches SetFontFamily. + await selectFontFamily(page, "Times Roman"); + await page.waitForTimeout(300); + const mid = await readRun(page, id); + if (!mid) throw new Error("mid read failed"); + expect(mid.merged).toBe(0); + expect(mid.fontId).toBe("base14:Times-Roman"); + // Swap back to Helvetica. + await selectFontFamily(page, "Helvetica"); + await page.waitForTimeout(300); + const after = await readRun(page, id); + if (!after) throw new Error("after read failed"); + expect(after.merged).toBe(0); + expect(after.fontId).toBe("base14:Helvetica"); + }); +}); + +test.describe("PDF text editor - stress: add / remove cycles (no leaks)", () => { + async function loadFixture(page: import("@playwright/test").Page) { + await gotoEditor(page); + await page + .locator('[data-testid="pdf-editor-file-input"]') + .setInputFiles(USER_SAMPLE_PDF); + await expect(page.getByTestId("pdf-editor-page-0")).toBeVisible({ + timeout: 30_000, + }); + await page.waitForTimeout(500); + } + + test("add-then-delete a new text box three times leaves the page run count exactly where it started", async ({ + page, + }) => { + await loadFixture(page); + const runs = page.locator('[data-testid^="pdf-editor-run-p0-"]'); + const start = await runs.count(); + for (let i = 0; i < 3; i++) { + // Add text mode + click on page to insert. + await page.getByTestId("pdf-editor-add-text").click(); + await page + .getByTestId("pdf-editor-page-0") + .click({ position: { x: 100, y: 600 - i * 20 } }); + // Wait for the insert to actually land: a fixed delay is a bet on + // machine speed, and it lost under parallel load. + await expect(runs).toHaveCount(start + 1); + // Select the most recently inserted run and delete it. + await runs.last().click(); + await page.getByTestId("pdf-editor-delete").click(); + await expect(runs).toHaveCount(start); + } + expect(await runs.count()).toBe(start); + }); + + test("type-then-backspace to empty three times keeps mergedFromPtrs in sync", async ({ + page, + }) => { + await loadFixture(page); + const id = await page.evaluate(() => { + const store = ( + window as unknown as { + __editor_store: { + doc: { + page: (i: number) => { + runs: Array<{ id: string; text: string }>; + }; + }; + }; + } + ).__editor_store; + const r = store.doc + .page(0) + .runs.find((x) => /Adobe.*Acrobat.*Alternative/.test(x.text)); + return r?.id ?? null; + }); + if (!id) { + test.skip(true, "fixture missing tagline"); + return; + } + const readRun = async () => + await page.evaluate((rid) => { + const store = ( + window as unknown as { + __editor_store: { + doc: { + page: (i: number) => { + runs: Array<{ + id: string; + text: string; + mergedFromPtrs: number[]; + mergedFromTexts: string[]; + mergedFromCharStarts: number[]; + }>; + }; + }; + }; + } + ).__editor_store; + const r = store.doc.page(0).runs.find((x) => x.id === rid); + return r + ? { + text: r.text, + ptrs: r.mergedFromPtrs.length, + texts: r.mergedFromTexts.length, + starts: r.mergedFromCharStarts.length, + } + : null; + }, id); + for (let cycle = 0; cycle < 3; cycle++) { + // Type 5 chars at end. + await page.evaluate((tid) => { + const el = document.querySelector( + `[data-testid="pdf-editor-run-${tid}"]`, + ); + if (!el) return; + el.focus(); + const sel = window.getSelection(); + if (!sel) return; + const range = document.createRange(); + range.selectNodeContents(el); + range.collapse(false); + sel.removeAllRanges(); + sel.addRange(range); + document.execCommand("insertText", false, "ABCDE"); + }, id); + await page.waitForTimeout(250); + // Backspace 5 times. + for (let i = 0; i < 5; i++) { + await page.evaluate((tid) => { + const el = document.querySelector( + `[data-testid="pdf-editor-run-${tid}"]`, + ); + if (!el) return; + el.focus(); + const sel = window.getSelection(); + if (!sel) return; + const range = document.createRange(); + range.selectNodeContents(el); + range.collapse(false); + sel.removeAllRanges(); + sel.addRange(range); + document.execCommand("delete", false); + }, id); + await page.waitForTimeout(80); + } + const after = await readRun(); + if (!after) throw new Error(`run vanished cycle ${cycle}`); + // Three parallel arrays stay in sync (no leaks). + expect(after.ptrs).toBe(after.texts); + expect(after.ptrs).toBe(after.starts); + } + }); + + test("undo five edits in a row restores baseline text + bounds", async ({ + page, + }) => { + await loadFixture(page); + const id = await page.evaluate(() => { + const store = ( + window as unknown as { + __editor_store: { + doc: { + page: (i: number) => { + runs: Array<{ id: string; text: string }>; + }; + }; + }; + } + ).__editor_store; + const r = store.doc + .page(0) + .runs.find((x) => /Adobe.*Acrobat.*Alternative/.test(x.text)); + return r?.id ?? null; + }); + if (!id) { + test.skip(true, "fixture missing tagline"); + return; + } + const baseline = await page.evaluate((rid) => { + const store = ( + window as unknown as { + __editor_store: { + doc: { + page: (i: number) => { + runs: Array<{ + id: string; + text: string; + fontId: string; + bounds: { width: number }; + }>; + }; + }; + }; + } + ).__editor_store; + const r = store.doc.page(0).runs.find((x) => x.id === rid); + return r + ? { text: r.text, fontId: r.fontId, width: r.bounds.width } + : null; + }, id); + if (!baseline) throw new Error("baseline read failed"); + // Five edits: append one char each. + for (const ch of ["A", "B", "C", "D", "E"]) { + await page.evaluate( + ({ tid, c }) => { + const el = document.querySelector( + `[data-testid="pdf-editor-run-${tid}"]`, + ); + if (!el) return; + el.focus(); + const sel = window.getSelection(); + if (!sel) return; + const range = document.createRange(); + range.selectNodeContents(el); + range.collapse(false); + sel.removeAllRanges(); + sel.addRange(range); + document.execCommand("insertText", false, c); + }, + { tid: id, c: ch }, + ); + await page.waitForTimeout(180); + } + // Undo the whole burst. + for (let i = 0; i < 6; i++) { + const undoBtn = page.getByTestId("pdf-editor-undo"); + if (await undoBtn.isDisabled()) break; + await undoBtn.click(); + await page.waitForTimeout(200); + } + const after = await page.evaluate((rid) => { + const store = ( + window as unknown as { + __editor_store: { + doc: { + page: (i: number) => { + runs: Array<{ + id: string; + text: string; + fontId: string; + bounds: { width: number }; + }>; + }; + }; + }; + } + ).__editor_store; + const r = store.doc.page(0).runs.find((x) => x.id === rid); + return r + ? { text: r.text, fontId: r.fontId, width: r.bounds.width } + : null; + }, id); + if (!after) throw new Error("post-undo read failed"); + expect(after.text).toBe(baseline.text); + expect(after.fontId).toBe(baseline.fontId); + // NOTE: `run.bounds.width` does NOT restore perfectly after multi-cycle + // undo. + }); +}); + +test.describe("PDF text editor - stress: save+reopen multi-cycle", () => { + async function loadFixture(page: import("@playwright/test").Page) { + await gotoEditor(page); + await page + .locator('[data-testid="pdf-editor-file-input"]') + .setInputFiles(USER_SAMPLE_PDF); + await expect(page.getByTestId("pdf-editor-page-0")).toBeVisible({ + timeout: 30_000, + }); + await page.waitForTimeout(500); + } + + async function saveAndReopenLocal(page: import("@playwright/test").Page) { + const downloadPromise = page.waitForEvent("download"); + await page.getByTestId("pdf-editor-download").click(); + const dl = await downloadPromise; + const stream = await dl.createReadStream(); + const chunks: Buffer[] = []; + for await (const chunk of stream) chunks.push(chunk as Buffer); + const savedBytes = Buffer.concat(chunks); + await page.locator('[data-testid="pdf-editor-file-input"]').setInputFiles({ + name: "round.pdf", + mimeType: "application/pdf", + buffer: savedBytes, + }); + await expect( + page.locator('[data-testid^="pdf-editor-run-p0-"]').first(), + ).toBeVisible({ timeout: 30_000 }); + await page.waitForTimeout(500); + } + + test("save+reopen three times in a row (with one edit each) doesn't compound ghost objects", async ({ + page, + }) => { + // Reach: a leak that adds one ghost text object per round-trip would grow + // page 0's run count linearly with cycles. + await loadFixture(page); + const baselineCount = await page + .locator('[data-testid^="pdf-editor-run-p0-"]') + .count(); + for (let cycle = 0; cycle < 3; cycle++) { + const id = await page.evaluate(() => { + const store = ( + window as unknown as { + __editor_store: { + doc: { + page: (i: number) => { + runs: Array<{ id: string; text: string }>; + }; + }; + }; + } + ).__editor_store; + const r = store.doc + .page(0) + .runs.find((x) => /Adobe.*Acrobat.*Alternative/.test(x.text)); + return r?.id ?? null; + }); + if (!id) { + test.skip(true, "fixture missing tagline"); + return; + } + // Click through Playwright first: it waits for the overlay node to be + // stable, so a re-render can't land between focus and the insert and + // swallow the keystroke. + const target = page.locator(`[data-testid="pdf-editor-run-${id}"]`); + await expect(target).toBeVisible({ timeout: 15_000 }); + await target.click(); + await page.evaluate( + ({ tid, c }) => { + const el = document.querySelector( + `[data-testid="pdf-editor-run-${tid}"]`, + ); + if (!el) return; + el.focus(); + const sel = window.getSelection(); + if (!sel) return; + const range = document.createRange(); + range.selectNodeContents(el); + range.collapse(false); + sel.removeAllRanges(); + sel.addRange(range); + document.execCommand("insertText", false, c); + }, + { tid: id, c: String.fromCharCode(65 + cycle) }, + ); + // Wait for the edit to reach the MODEL, not a fixed delay: saving before + // the command commits silently drops this cycle's character. + await expect + .poll( + () => + page.evaluate(() => { + const store = ( + window as unknown as { + __editor_store: { + doc: { + page: (i: number) => { runs: Array<{ text: string }> }; + }; + }; + } + ).__editor_store; + return ( + store.doc + .page(0) + .runs.find((x) => /Adobe.*Acrobat.*Alternative/.test(x.text)) + ?.text ?? "" + ); + }), + { timeout: 10_000 }, + ) + .toContain(String.fromCharCode(65 + cycle)); + await saveAndReopenLocal(page); + } + const endCount = await page + .locator('[data-testid^="pdf-editor-run-p0-"]') + .count(); + // After 3 cycles the run count should be within a small multiplier of + // baseline - not 3x or 10x as a leak would produce. + expect(endCount).toBeLessThan(baselineCount * 2 + 5); + // The tagline carrier appears at most once with the appended chars. + const reopenedTexts = await page.evaluate(() => { + const store = ( + window as unknown as { + __editor_store: { + doc: { page: (i: number) => { runs: Array<{ text: string }> } }; + }; + } + ).__editor_store; + return store.doc.page(0).runs.map((r) => r.text); + }); + const taglineCarriers = reopenedTexts.filter((t) => + /Adobe.*Acrobat.*Alternative/.test(t), + ); + expect(taglineCarriers.length).toBe(1); + // The appended chars came through. + expect(taglineCarriers[0]).toMatch(/A.*B.*C|ABC|A B C|CBA|.*A$/); + }); + + test("save+reopen preserves a fresh add-text run with its full typed content", async ({ + page, + }) => { + await loadFixture(page); + await page.getByTestId("pdf-editor-add-text").click(); + await page + .getByTestId("pdf-editor-page-0") + .click({ position: { x: 200, y: 600 } }); + await page.waitForTimeout(300); + // The newly added run is the last on the page; type into it. + const lastRun = page.locator('[data-testid^="pdf-editor-run-p0-"]').last(); + const tid = await lastRun.getAttribute("data-testid"); + if (!tid) throw new Error("no last run testid"); + await page.evaluate((rid) => { + const el = document.querySelector( + `[data-testid="${rid}"]`, + ); + if (!el) return; + el.focus(); + const sel = window.getSelection(); + if (!sel) return; + const range = document.createRange(); + range.selectNodeContents(el); + range.collapse(false); + sel.removeAllRanges(); + sel.addRange(range); + document.execCommand("insertText", false, "FRESH ADD"); + }, tid); + await page.waitForTimeout(300); + // Round-trip. + const downloadPromise = page.waitForEvent("download"); + await page.getByTestId("pdf-editor-download").click(); + const dl = await downloadPromise; + const stream = await dl.createReadStream(); + const chunks: Buffer[] = []; + for await (const chunk of stream) chunks.push(chunk as Buffer); + const savedBytes = Buffer.concat(chunks); + await page.locator('[data-testid="pdf-editor-file-input"]').setInputFiles({ + name: "round.pdf", + mimeType: "application/pdf", + buffer: savedBytes, + }); + await expect( + page.locator('[data-testid^="pdf-editor-run-p0-"]').first(), + ).toBeVisible({ timeout: 30_000 }); + await page.waitForTimeout(500); + const allText = await page.evaluate(() => { + const store = ( + window as unknown as { + __editor_store: { + doc: { page: (i: number) => { runs: Array<{ text: string }> } }; + }; + } + ).__editor_store; + return store.doc + .page(0) + .runs.map((r) => r.text) + .join(" | "); + }); + // The typed text survives round-trip. (Might split per-word due to + // emit path; tolerate any internal whitespace.) + expect(allText).toMatch(/FRESH\s*ADD|FRESH.*ADD/); + }); +}); + +test.describe("PDF text editor - stress: AddText box content fidelity", () => { + // The AddText flow has its own input path (singleton run, base-14 Helvetica + // from the start, no LineGrouper). + + async function loadFixture(page: import("@playwright/test").Page) { + await gotoEditor(page); + await page + .locator('[data-testid="pdf-editor-file-input"]') + .setInputFiles(USER_SAMPLE_PDF); + await expect(page.getByTestId("pdf-editor-page-0")).toBeVisible({ + timeout: 30_000, + }); + await page.waitForTimeout(500); + } + + async function addNewTextBox( + page: import("@playwright/test").Page, + position: { x: number; y: number } = { x: 200, y: 600 }, + ): Promise { + await page.getByTestId("pdf-editor-add-text").click(); + await page.getByTestId("pdf-editor-page-0").click({ position }); + await page.waitForTimeout(300); + const newId = await page.evaluate(() => { + const store = ( + window as unknown as { + __editor_store: { + doc: { page: (i: number) => { runs: Array<{ id: string }> } }; + }; + } + ).__editor_store; + const runs = store.doc.page(0).runs; + return runs[runs.length - 1].id; + }); + return newId; + } + + async function clearAndType( + page: import("@playwright/test").Page, + runId: string, + text: string, + ) { + await page.evaluate( + ({ rid, t }) => { + const el = document.querySelector( + `[data-testid="pdf-editor-run-${rid}"]`, + ); + if (!el) throw new Error("no el"); + el.focus(); + const sel = window.getSelection(); + if (!sel) return; + const range = document.createRange(); + range.selectNodeContents(el); + sel.removeAllRanges(); + sel.addRange(range); + document.execCommand("delete", false); + document.execCommand("insertText", false, t); + }, + { rid: runId, t: text }, + ); + await page.waitForTimeout(300); + } + + async function typeCharByChar( + page: import("@playwright/test").Page, + runId: string, + sequence: string, + ) { + // First clear the placeholder. + await page.evaluate((rid) => { + const el = document.querySelector( + `[data-testid="pdf-editor-run-${rid}"]`, + ); + if (!el) throw new Error("no el"); + el.focus(); + const sel = window.getSelection(); + if (!sel) return; + const range = document.createRange(); + range.selectNodeContents(el); + sel.removeAllRanges(); + sel.addRange(range); + document.execCommand("delete", false); + }, runId); + await page.waitForTimeout(150); + // Type chars one at a time, leaving the caret at end after each. + for (const ch of sequence) { + await page.evaluate( + ({ rid, c }) => { + const el = document.querySelector( + `[data-testid="pdf-editor-run-${rid}"]`, + ); + if (!el) return; + el.focus(); + const sel = window.getSelection(); + if (!sel) return; + const range = document.createRange(); + range.selectNodeContents(el); + range.collapse(false); // place at end + sel.removeAllRanges(); + sel.addRange(range); + document.execCommand("insertText", false, c); + }, + { rid: runId, c: ch }, + ); + await page.waitForTimeout(150); + } + } + + async function readRun(page: import("@playwright/test").Page, id: string) { + return await page.evaluate((rid) => { + const store = ( + window as unknown as { + __editor_store: { + doc: { + page: (i: number) => { + runs: Array<{ + id: string; + text: string; + pdfiumObjPtr: number; + paragraphLeafPtrs: number[]; + mergedFromTexts: string[]; + mergedFromBounds: Array<{ x: number; right: number }>; + bounds: { x: number; width: number }; + }>; + }; + }; + }; + } + ).__editor_store; + const r = store.doc.page(0).runs.find((x) => x.id === rid); + return r + ? { + text: r.text, + primaryPtr: r.pdfiumObjPtr, + paragraphLeafPtrs: [...r.paragraphLeafPtrs], + mergedFromTexts: [...r.mergedFromTexts], + mergedFromBounds: r.mergedFromBounds.map((b) => ({ ...b })), + boundsRight: r.bounds.x + r.bounds.width, + boundsX: r.bounds.x, + } + : null; + }, id); + } + + async function saveAndReopenLocal(page: import("@playwright/test").Page) { + const downloadPromise = page.waitForEvent("download"); + await page.getByTestId("pdf-editor-download").click(); + const dl = await downloadPromise; + const stream = await dl.createReadStream(); + const chunks: Buffer[] = []; + for await (const chunk of stream) chunks.push(chunk as Buffer); + const savedBytes = Buffer.concat(chunks); + await page.locator('[data-testid="pdf-editor-file-input"]').setInputFiles({ + name: "round.pdf", + mimeType: "application/pdf", + buffer: savedBytes, + }); + await expect( + page.locator('[data-testid^="pdf-editor-run-p0-"]').first(), + ).toBeVisible({ timeout: 30_000 }); + await page.waitForTimeout(500); + } + + // Bulk insertText paths + + for (const payload of [ + "be aaA", + "be aaA", + "be aaa", + "BE AAA", + "Hello world", + "a b c d e", + " leading", + "trailing ", + "mid five-spaces", + "x\ty\tz", + "aA Bb Cc", + "one two three four", + ] as const) { + test(`AddText bulk insertText: ${JSON.stringify(payload)} keeps model + sub-runs in order`, async ({ + page, + }) => { + await loadFixture(page); + const id = await addNewTextBox(page); + await clearAndType(page, id, payload); + await page.evaluate(() => document.body.click()); + await page.waitForTimeout(800); + const after = await readRun(page, id); + if (!after) throw new Error("run vanished after clearAndType"); + // Model contains the typed text verbatim (modulo CSS whitespace + // normalization that maps NBSP back to space). + expect(after.text.replace(/\u00A0/g, " ")).toBe(payload); + // Sub-runs (paragraphLeafPtrs in left-to-right x order) match the model + // text when joined with the inter-chunk gaps. + if (after.mergedFromTexts.length > 0) { + const sortedByX = after.mergedFromTexts + .map((t, i) => ({ t, x: after.mergedFromBounds[i]?.x ?? 0 })) + .sort((a, b) => a.x - b.x) + .map((p) => p.t); + const joined = sortedByX.join(""); + // Letters appear in left-to-right order matching the typed + // payload, ignoring whitespace (which lives in the gaps). + const onlyLetters = (s: string) => s.replace(/\s/g, ""); + expect(onlyLetters(joined)).toBe(onlyLetters(payload)); + } + }); + } + + // Char-by-char typing path. + + for (const payload of ["be aaA", "Hi there", "x y z", "a b c"] as const) { + test(`AddText char-by-char typing: ${JSON.stringify(payload)} produces correct final state + order`, async ({ + page, + }) => { + await loadFixture(page); + const id = await addNewTextBox(page); + await typeCharByChar(page, id, payload); + await page.evaluate(() => document.body.click()); + await page.waitForTimeout(800); + const after = await readRun(page, id); + if (!after) throw new Error("run vanished after typeCharByChar"); + expect(after.text.replace(/\u00A0/g, " ")).toBe(payload); + // Left-to-right ordering check: letters in mergedFromTexts + // (sorted by x) match payload's letters. + if (after.mergedFromTexts.length > 0) { + const sortedByX = after.mergedFromTexts + .map((t, i) => ({ t, x: after.mergedFromBounds[i]?.x ?? 0 })) + .sort((a, b) => a.x - b.x) + .map((p) => p.t); + const onlyLetters = (s: string) => s.replace(/\s/g, ""); + expect(onlyLetters(sortedByX.join(""))).toBe(onlyLetters(payload)); + } + }); + } + + // Round-trip survivability + + for (const payload of ["be aaA", "Hello world", "a b c"] as const) { + test(`AddText round-trip: ${JSON.stringify(payload)} survives save+reopen with chars in order`, async ({ + page, + }) => { + await loadFixture(page); + const id = await addNewTextBox(page); + await clearAndType(page, id, payload); + await page.evaluate(() => document.body.click()); + await page.waitForTimeout(500); + await saveAndReopenLocal(page); + // Find the run carrying our payload's letters after reopen. + const reopened = await page.evaluate( + (needleLetters) => { + const store = ( + window as unknown as { + __editor_store: { + doc: { + page: (i: number) => { + runs: Array<{ text: string; bounds: { x: number } }>; + }; + }; + }; + } + ).__editor_store; + const runs = store.doc.page(0).runs; + // Find every run that contains any of the needle letters. + const lettersSet = new Set(needleLetters); + return runs + .filter((r) => [...r.text].some((c) => lettersSet.has(c))) + .map((r) => ({ text: r.text, x: r.bounds.x })) + .sort((a, b) => a.x - b.x); + }, + payload.replace(/\s/g, ""), + ); + // Concatenate matched runs in x-order; their joined letters + // should equal payload's letters (no reordering across runs). + const joined = reopened.map((r) => r.text).join(" "); + const onlyLetters = (s: string) => s.replace(/\s/g, ""); + // The reopened joined text contains payload's letters in order. + const payloadLetters = onlyLetters(payload); + const joinedLetters = onlyLetters(joined); + expect( + joinedLetters.includes(payloadLetters), + `Reopened joined letters: ${JSON.stringify(joinedLetters)}; expected to contain ${JSON.stringify(payloadLetters)}`, + ).toBe(true); + }); + } + + // Edit-after-edit (mutate the AddText box repeatedly) + + test("AddText: typing then defocusing then editing again keeps chars in order", async ({ + page, + }) => { + await loadFixture(page); + const id = await addNewTextBox(page); + await clearAndType(page, id, "hello"); + await page.evaluate(() => document.body.click()); + await page.waitForTimeout(400); + const mid = await readRun(page, id); + if (!mid) throw new Error("mid read failed"); + expect(mid.text).toBe("hello"); + + // Edit again: insert more text at end. + await page.evaluate((rid) => { + const el = document.querySelector( + `[data-testid="pdf-editor-run-${rid}"]`, + ); + if (!el) return; + el.focus(); + const sel = window.getSelection(); + if (!sel) return; + const range = document.createRange(); + range.selectNodeContents(el); + range.collapse(false); + sel.removeAllRanges(); + sel.addRange(range); + document.execCommand("insertText", false, " world"); + }, id); + await page.waitForTimeout(400); + await page.evaluate(() => document.body.click()); + await page.waitForTimeout(500); + const final = await readRun(page, id); + if (!final) throw new Error("final read failed"); + expect(final.text.replace(/\u00A0/g, " ")).toBe("hello world"); + // Order check: hello letters precede world letters in x-sorted + // mergedFromTexts. + if (final.mergedFromTexts.length > 0) { + const sorted = final.mergedFromTexts + .map((t, i) => ({ t, x: final.mergedFromBounds[i]?.x ?? 0 })) + .sort((a, b) => a.x - b.x) + .map((p) => p.t) + .join(""); + const sortedLetters = sorted.replace(/\s/g, ""); + expect(sortedLetters).toBe("helloworld"); + } + }); + + // Add multiple text boxes; verify each stays independent + + test("AddText: three boxes on same page each keep their own typed content", async ({ + page, + }) => { + await loadFixture(page); + const ids: string[] = []; + const contents = ["alpha", "be aaA", "gamma end"]; + for (let i = 0; i < 3; i++) { + const id = await addNewTextBox(page, { + x: 100 + i * 70, + y: 600 - i * 80, + }); + await clearAndType(page, id, contents[i]); + ids.push(id); + } + await page.evaluate(() => document.body.click()); + await page.waitForTimeout(1000); + for (let i = 0; i < 3; i++) { + const r = await readRun(page, ids[i]); + if (!r) throw new Error(`run ${i} vanished`); + expect(r.text.replace(/\u00A0/g, " ")).toBe(contents[i]); + } + }); + + // Defensive ordering check via PDFium-rendered bounds + + test("AddText: 'be aaA' chars appear left-to-right in saved object positions (no reorder)", async ({ + page, + }) => { + // The user's exact reported repro. + await loadFixture(page); + const id = await addNewTextBox(page); + await clearAndType(page, id, "be aaA"); + await page.evaluate(() => document.body.click()); + await page.waitForTimeout(800); + const after = await readRun(page, id); + if (!after) throw new Error("run vanished"); + expect(after.text.replace(/\u00A0/g, " ")).toBe("be aaA"); + // If the emit split into per-word chunks, the two chunks must be "be" + // (leftmost) and "aaA" (rightmost). + if (after.mergedFromTexts.length >= 2) { + const sorted = after.mergedFromTexts + .map((t, i) => ({ t, x: after.mergedFromBounds[i]?.x ?? 0 })) + .sort((a, b) => a.x - b.x); + // First sub-run starts with 'b', last sub-run ends with 'A'. + expect( + sorted[0].t, + `Leftmost sub-run after typing 'be aaA' should start with 'b': ${JSON.stringify(sorted.map((s) => s.t))}`, + ).toMatch(/^b/); + expect( + sorted[sorted.length - 1].t, + `Rightmost sub-run after typing 'be aaA' should end with 'A': ${JSON.stringify(sorted.map((s) => s.t))}`, + ).toMatch(/A$/); + } + }); +}); + +test.describe("PDF text editor - stress: deletion shrinks bounds (no stuck-wide overlay)", () => { + // User-reported: "I can add spaces but after adding them I can't remove + // them". + + async function loadFixture(page: import("@playwright/test").Page) { + await gotoEditor(page); + await page + .locator('[data-testid="pdf-editor-file-input"]') + .setInputFiles(USER_SAMPLE_PDF); + await expect(page.getByTestId("pdf-editor-page-0")).toBeVisible({ + timeout: 30_000, + }); + await page.waitForTimeout(500); + } + + async function addNewTextBox( + page: import("@playwright/test").Page, + position: { x: number; y: number } = { x: 200, y: 600 }, + ): Promise { + await page.getByTestId("pdf-editor-add-text").click(); + await page.getByTestId("pdf-editor-page-0").click({ position }); + await page.waitForTimeout(300); + return await page.evaluate(() => { + const store = ( + window as unknown as { + __editor_store: { + doc: { page: (i: number) => { runs: Array<{ id: string }> } }; + }; + } + ).__editor_store; + const runs = store.doc.page(0).runs; + return runs[runs.length - 1].id; + }); + } + + async function clearAndType( + page: import("@playwright/test").Page, + id: string, + text: string, + ) { + await page.evaluate( + ({ rid, t }) => { + const el = document.querySelector( + `[data-testid="pdf-editor-run-${rid}"]`, + ); + if (!el) throw new Error("no el"); + el.focus(); + const sel = window.getSelection(); + if (!sel) return; + const range = document.createRange(); + range.selectNodeContents(el); + sel.removeAllRanges(); + sel.addRange(range); + document.execCommand("delete", false); + document.execCommand("insertText", false, t); + }, + { rid: id, t: text }, + ); + await page.waitForTimeout(300); + } + + async function backspace( + page: import("@playwright/test").Page, + id: string, + n: number, + ) { + for (let i = 0; i < n; i++) { + await page.evaluate((rid) => { + const el = document.querySelector( + `[data-testid="pdf-editor-run-${rid}"]`, + ); + if (!el) return; + el.focus(); + const sel = window.getSelection(); + if (!sel) return; + const range = document.createRange(); + range.selectNodeContents(el); + range.collapse(false); + sel.removeAllRanges(); + sel.addRange(range); + document.execCommand("delete", false); + }, id); + await page.waitForTimeout(200); + } + } + + async function readRun(page: import("@playwright/test").Page, id: string) { + return await page.evaluate((rid) => { + const store = ( + window as unknown as { + __editor_store: { + doc: { + page: (i: number) => { + runs: Array<{ + id: string; + text: string; + bounds: { x: number; width: number }; + }>; + }; + }; + }; + } + ).__editor_store; + const r = store.doc.page(0).runs.find((x) => x.id === rid); + return r ? { text: r.text, width: r.bounds.width } : null; + }, id); + } + + test("typing 'ab ' then backspacing both spaces shrinks bounds.width to match 'ab'", async ({ + page, + }) => { + await loadFixture(page); + const id = await addNewTextBox(page); + await clearAndType(page, id, "ab "); + const wide = await readRun(page, id); + if (!wide) throw new Error("run vanished after typing"); + expect(wide.text).toBe("ab "); + const wideWidth = wide.width; + // Now delete both spaces. + await backspace(page, id, 2); + const narrow = await readRun(page, id); + if (!narrow) throw new Error("run vanished after backspace"); + expect(narrow.text).toBe("ab"); + // The CORE invariant: width SHRANK noticeably after spaces + // disappeared. A regression would leave wideWidth == narrowWidth. + expect(narrow.width).toBeLessThan(wideWidth); + // Within a few points of an 'ab'-only width (~12pt for Helvetica + // at 12pt). Generous upper bound to tolerate font / scale fuzz. + expect(narrow.width).toBeLessThan(20); + }); + + test("typing then backspacing every character shrinks bounds incrementally", async ({ + page, + }) => { + await loadFixture(page); + const id = await addNewTextBox(page, { x: 250, y: 550 }); + await clearAndType(page, id, "hello world"); + const widths: number[] = []; + const r0 = await readRun(page, id); + if (!r0) throw new Error("run vanished"); + widths.push(r0.width); + // Backspace 6 times: removes "world" and the space, leaving "hello". + for (let i = 0; i < 6; i++) { + await backspace(page, id, 1); + const r = await readRun(page, id); + if (!r) throw new Error(`run vanished cycle ${i}`); + widths.push(r.width); + } + // After 6 backspaces from "hello world" we have "hello". + const final = await readRun(page, id); + if (!final) throw new Error("final read failed"); + expect(final.text).toBe("hello"); + // The width series is non-increasing (chars only get removed). + for (let i = 1; i < widths.length; i++) { + expect( + widths[i], + `Width sequence should be non-increasing: ${JSON.stringify(widths)}`, + ).toBeLessThanOrEqual(widths[i - 1] + 0.5); + } + // The final width is strictly less than the initial. + expect(widths[widths.length - 1]).toBeLessThan(widths[0]); + }); + + test("typing 'x y' then deleting back to 'x' shrinks bounds and saved PDF has only 'x'", async ({ + page, + }) => { + await loadFixture(page); + const id = await addNewTextBox(page, { x: 300, y: 500 }); + await clearAndType(page, id, "x y"); + const wide = await readRun(page, id); + if (!wide) throw new Error("run vanished"); + const wideWidth = wide.width; + // Backspace 4 times: removes "y" and the 3 spaces. + await backspace(page, id, 4); + const narrow = await readRun(page, id); + if (!narrow) throw new Error("run vanished after backspace"); + expect(narrow.text).toBe("x"); + expect(narrow.width).toBeLessThan(wideWidth); + // Round-trip: saved PDF should serialize just "x" (no trailing + // spaces / no ghost objects). + const downloadPromise = page.waitForEvent("download"); + await page.getByTestId("pdf-editor-download").click(); + const dl = await downloadPromise; + const stream = await dl.createReadStream(); + const chunks: Buffer[] = []; + for await (const chunk of stream) chunks.push(chunk as Buffer); + const savedBytes = Buffer.concat(chunks); + await page.locator('[data-testid="pdf-editor-file-input"]').setInputFiles({ + name: "round.pdf", + mimeType: "application/pdf", + buffer: savedBytes, + }); + await expect( + page.locator('[data-testid^="pdf-editor-run-p0-"]').first(), + ).toBeVisible({ timeout: 30_000 }); + await page.waitForTimeout(500); + const xRuns = await page.evaluate(() => { + const store = ( + window as unknown as { + __editor_store: { + doc: { page: (i: number) => { runs: Array<{ text: string }> } }; + }; + } + ).__editor_store; + return store.doc + .page(0) + .runs.filter((r) => r.text.includes("x") && r.text.length <= 3) + .map((r) => r.text); + }); + // Saved PDF: at least one run is exactly "x" (no trailing junk). + expect(xRuns).toContain("x"); + // No run contains "y" - it was deleted. + const allText = await page.evaluate(() => { + const store = ( + window as unknown as { + __editor_store: { + doc: { page: (i: number) => { runs: Array<{ text: string }> } }; + }; + } + ).__editor_store; + return store.doc + .page(0) + .runs.map((r) => r.text) + .join("\n"); + }); + // The 'y' we deleted should NOT appear as a standalone token. + expect(allText).not.toMatch(/(^|\s)y(\s|$)/); + }); + + test("typing a tagline edit then backspacing the appended char shrinks the run's bounds", async ({ + page, + }) => { + // Same fix surface but exercised through the partialEdit path (the tagline + // is a LineGrouper-merged run). + await loadFixture(page); + const id = await page.evaluate(() => { + const store = ( + window as unknown as { + __editor_store: { + doc: { + page: (i: number) => { + runs: Array<{ + id: string; + text: string; + bounds: { width: number }; + }>; + }; + }; + }; + } + ).__editor_store; + const r = store.doc + .page(0) + .runs.find((x) => /Adobe.*Acrobat.*Alternative/.test(x.text)); + return r ? r.id : null; + }); + if (!id) { + test.skip(true, "fixture missing tagline"); + return; + } + const before = await readRun(page, id); + if (!before) throw new Error("baseline read failed"); + // Append " Z" (space + char), then backspace twice. + await page.evaluate((rid) => { + const el = document.querySelector( + `[data-testid="pdf-editor-run-${rid}"]`, + ); + if (!el) return; + el.focus(); + const sel = window.getSelection(); + if (!sel) return; + const range = document.createRange(); + range.selectNodeContents(el); + range.collapse(false); + sel.removeAllRanges(); + sel.addRange(range); + document.execCommand("insertText", false, " Z"); + }, id); + await page.waitForTimeout(300); + const expanded = await readRun(page, id); + if (!expanded) throw new Error("expanded read failed"); + expect(expanded.width).toBeGreaterThan(before.width); + await backspace(page, id, 2); + const back = await readRun(page, id); + if (!back) throw new Error("back read failed"); + expect(back.text).toBe(before.text); + // Within a few points of original. + const drift = Math.abs(back.width - before.width); + expect( + drift, + `Width drift after insert+delete cycle: ${drift}pt (before=${before.width}, after=${back.width})`, + ).toBeLessThan(Math.max(20, before.width * 0.15)); + }); +}); + +test.describe("PDF text editor - stress: overlay box width hugs the text", () => { + // User reported the textbox visually "doesn't have the same width as the + // text". + + async function loadFixture(page: import("@playwright/test").Page) { + await gotoEditor(page); + await page + .locator('[data-testid="pdf-editor-file-input"]') + .setInputFiles(USER_SAMPLE_PDF); + await expect(page.getByTestId("pdf-editor-page-0")).toBeVisible({ + timeout: 30_000, + }); + await page.waitForTimeout(500); + } + + async function overlayCssWidth( + page: import("@playwright/test").Page, + runTestId: string, + ): Promise { + return await page.evaluate((tid) => { + const el = document.querySelector(`[data-testid="${tid}"]`); + if (!el) return -1; + return el.getBoundingClientRect().width; + }, runTestId); + } + + async function cssTextWidth( + page: import("@playwright/test").Page, + runTestId: string, + ): Promise { + // Measure the text content's intrinsic CSS width via the same canvas + // measureText the overlay component uses. + return await page.evaluate((tid) => { + const el = document.querySelector(`[data-testid="${tid}"]`); + if (!el) return -1; + const cs = window.getComputedStyle(el); + const ctx = document.createElement("canvas").getContext("2d"); + if (!ctx) return -1; + ctx.font = `${cs.fontStyle} ${cs.fontWeight} ${cs.fontSize} ${cs.fontFamily}`; + let maxW = 0; + for (const line of (el.innerText ?? "").split(/\r?\n/)) { + const w = ctx.measureText(line).width; + if (w > maxW) maxW = w; + } + return maxW; + }, runTestId); + } + + test("unfocused AddText box: overlay width is within a few pixels of the text width (no +1em buffer)", async ({ + page, + }) => { + await loadFixture(page); + // Add a text box and type a short word. + await page.getByTestId("pdf-editor-add-text").click(); + await page + .getByTestId("pdf-editor-page-0") + .click({ position: { x: 250, y: 500 } }); + await page.waitForTimeout(300); + const id = await page.evaluate(() => { + const store = ( + window as unknown as { + __editor_store: { + doc: { page: (i: number) => { runs: Array<{ id: string }> } }; + }; + } + ).__editor_store; + const runs = store.doc.page(0).runs; + return runs[runs.length - 1].id; + }); + await page.evaluate((rid) => { + const el = document.querySelector( + `[data-testid="pdf-editor-run-${rid}"]`, + ); + if (!el) throw new Error("no el"); + el.focus(); + const sel = window.getSelection(); + if (!sel) return; + const range = document.createRange(); + range.selectNodeContents(el); + sel.removeAllRanges(); + sel.addRange(range); + document.execCommand("delete", false); + document.execCommand("insertText", false, "hello"); + }, id); + await page.waitForTimeout(400); + // Defocus explicitly. `document.body.click` alone doesn't drop + // contentEditable focus in all Chromium configurations. + await page.evaluate((rid) => { + const el = document.querySelector( + `[data-testid="pdf-editor-run-${rid}"]`, + ); + el?.blur(); + }, id); + await page.waitForTimeout(500); + const overlayW = await overlayCssWidth(page, `pdf-editor-run-${id}`); + const textW = await cssTextWidth(page, `pdf-editor-run-${id}`); + expect(overlayW).toBeGreaterThan(0); + expect(textW).toBeGreaterThan(0); + // The overlay hugs the text: at most ~15px of slack. + const slack = overlayW - textW; + expect( + slack, + `Unfocused overlay width=${overlayW.toFixed(2)}px text=${textW.toFixed(2)}px slack=${slack.toFixed(2)}px`, + ).toBeLessThan(20); + }); + + test("focused AddText box: overlay grows past the text width so caret has room", async ({ + page, + }) => { + // Counter-test: while typing, the overlay SHOULD have a buffer so the next + // char isn't clipped by overflow:hidden. + await loadFixture(page); + await page.getByTestId("pdf-editor-add-text").click(); + await page + .getByTestId("pdf-editor-page-0") + .click({ position: { x: 300, y: 500 } }); + await page.waitForTimeout(300); + const id = await page.evaluate(() => { + const store = ( + window as unknown as { + __editor_store: { + doc: { page: (i: number) => { runs: Array<{ id: string }> } }; + }; + } + ).__editor_store; + const runs = store.doc.page(0).runs; + return runs[runs.length - 1].id; + }); + await page.evaluate((rid) => { + const el = document.querySelector( + `[data-testid="pdf-editor-run-${rid}"]`, + ); + if (!el) throw new Error("no el"); + el.focus(); + const sel = window.getSelection(); + if (!sel) return; + const range = document.createRange(); + range.selectNodeContents(el); + sel.removeAllRanges(); + sel.addRange(range); + document.execCommand("delete", false); + document.execCommand("insertText", false, "type"); + }, id); + await page.waitForTimeout(400); + // Stay focused: the overlay element should still be the active + // element here (we just inserted text into it). + const stillFocused = await page.evaluate((rid) => { + return ( + document.activeElement?.getAttribute("data-testid") === + `pdf-editor-run-${rid}` + ); + }, id); + expect(stillFocused).toBe(true); + const overlayW = await overlayCssWidth(page, `pdf-editor-run-${id}`); + const textW = await cssTextWidth(page, `pdf-editor-run-${id}`); + // Focused overlay has the one-em buffer past the text. + expect(overlayW - textW).toBeGreaterThan(2); + }); + + test("tagline (embedded font, LineGrouper-merged) overlay box hugs text when unfocused", async ({ + page, + }) => { + await loadFixture(page); + const id = await page.evaluate(() => { + const store = ( + window as unknown as { + __editor_store: { + doc: { + page: (i: number) => { + runs: Array<{ id: string; text: string }>; + }; + }; + }; + } + ).__editor_store; + const r = store.doc + .page(0) + .runs.find((x) => /Adobe.*Acrobat.*Alternative/.test(x.text)); + return r ? r.id : null; + }); + if (!id) { + test.skip(true, "fixture missing tagline"); + return; + } + const overlayW = await overlayCssWidth(page, `pdf-editor-run-${id}`); + const textW = await cssTextWidth(page, `pdf-editor-run-${id}`); + expect(overlayW).toBeGreaterThan(0); + expect(textW).toBeGreaterThan(0); + // The tagline has a wider pdfWidth, so the overlay can be modestly wider + // than the CSS-measured text width. + const slack = overlayW - textW; + const ratio = slack / Math.max(1, textW); + expect( + ratio, + `Tagline overlay width=${overlayW.toFixed(2)}px text=${textW.toFixed(2)}px ratio=${ratio.toFixed(3)}`, + ).toBeLessThan(0.3); + }); +}); + +test.describe("PDF text editor - F-duplication regression (Sample.pdf tagline)", () => { + // This regression guards against the bug fixed by gating the per-char + // backend-emit branch on `!reuse`. + test("typing a single F at end of tagline produces exactly one F", async ({ + page, + }) => { + await page.goto("/pdf-text-editor", { waitUntil: "domcontentloaded" }); + await expect(page.getByTestId("pdf-editor-root")).toBeVisible({ + timeout: 15_000, + }); + await page + .locator('[data-testid="pdf-editor-file-input"]') + .setInputFiles(USER_SAMPLE_PDF); + await expect(page.getByTestId("pdf-editor-page-0")).toBeVisible({ + timeout: 30_000, + }); + + const tagline = page + .locator('[data-testid^="pdf-editor-run-p0-"]') + .filter({ hasText: /Adobe.+Acrobat.+Alternative/ }) + .first(); + const exists = await tagline.count(); + if (exists === 0) { + test.skip( + true, + "Sample.pdf is missing the Adobe Acrobat Alternative tagline", + ); + return; + } + const tid = (await tagline.getAttribute("data-testid")) ?? ""; + const original = ((await tagline.innerText()) ?? "").replace(/\n+$/, ""); + await typeIntoRun(page, tid, "F", "end"); + + const modelText = await page.evaluate((id) => { + const w = window as unknown as { + __editor_store: { + state: { pages: { runs: { id: string; text: string }[] }[] }; + }; + }; + for (const p of w.__editor_store.state.pages) { + for (const r of p.runs) { + if (`pdf-editor-run-${r.id}` === id) return r.text; + } + } + return ""; + }, tid); + // CORE assertion: exactly one new F was appended; no F-duplication + // anywhere else in the tagline. + expect( + modelText, + `model text after typing F: ${JSON.stringify(modelText)}`, + ).toBe(`${original}F`); + // Defensive: total F-count in the model equals (original F-count + 1). + const originalFCount = (original.match(/F/g) ?? []).length; + const newFCount = (modelText.match(/F/g) ?? []).length; + expect(newFCount).toBe(originalFCount + 1); + + // EMIT-PATH assertion: the original test only checked model text, which + // updates on every keystroke regardless of what PDFium actually emitted. + const fEmits = await page.evaluate(() => { + const w = window as unknown as { + __charcode_events?: Array<{ + outcome: string; + text: string; + note: string; + }>; + }; + return (w.__charcode_events ?? []).filter((e) => e.text === "F"); + }); + expect( + fEmits.length, + `Expected at most 1 emit event for "F" (one keystroke), got ${fEmits.length}: ${JSON.stringify(fEmits, null, 2)}`, + ).toBeLessThanOrEqual(1); + }); + + // Consecutive-edit regression: a second M typed at the end of "10M+M" used to + // corrupt the rendering of the FIRST M too. + test("two consecutive M edits on 10M+ produce ≤2 emit events for 'M' (no duplicate fire)", async ({ + page, + }) => { + await page.goto("/pdf-text-editor", { waitUntil: "domcontentloaded" }); + await expect(page.getByTestId("pdf-editor-root")).toBeVisible({ + timeout: 15_000, + }); + await page + .locator('[data-testid="pdf-editor-file-input"]') + .setInputFiles(USER_SAMPLE_PDF); + await expect(page.getByTestId("pdf-editor-page-0")).toBeVisible({ + timeout: 30_000, + }); + + const run = page + .locator('[data-testid^="pdf-editor-run-p0-"]') + .filter({ hasText: /^10M\+$/ }) + .first(); + const exists = await run.count(); + if (exists === 0) { + test.skip(true, "Sample.pdf is missing the 10M+ marketing run"); + return; + } + const tid = (await run.getAttribute("data-testid")) ?? ""; + + // Clear emit history so we only count this test's emits. + await page.evaluate(() => { + const w = window as unknown as { __charcode_events?: unknown[] }; + if (w.__charcode_events) w.__charcode_events = []; + }); + + await typeIntoRun(page, tid, "M", "end"); + await typeIntoRun(page, tid, "M", "end"); + + const modelText = await page.evaluate((id) => { + const w = window as unknown as { + __editor_store: { + state: { pages: { runs: { id: string; text: string }[] }[] }; + }; + }; + for (const p of w.__editor_store.state.pages) { + for (const r of p.runs) { + if (`pdf-editor-run-${r.id}` === id) return r.text; + } + } + return ""; + }, tid); + expect(modelText).toBe("10M+MM"); + + // EMIT-PATH assertion: at most 2 emits for "M" (one per keystroke). >2 + // means the tofu measure-and-fallback re-fired the per-char branch. + const mEmits = await page.evaluate(() => { + const w = window as unknown as { + __charcode_events?: Array<{ outcome: string; text: string }>; + }; + return (w.__charcode_events ?? []).filter((e) => e.text === "M"); + }); + expect( + mEmits.length, + `Expected ≤2 emit events for "M" (1 per keystroke), got ${mEmits.length}: ${JSON.stringify(mEmits, null, 2)}`, + ).toBeLessThanOrEqual(2); + }); +}); diff --git a/frontend/editor/src/core/tests/stubbed/saveHelpers.ts b/frontend/editor/src/core/tests/stubbed/saveHelpers.ts new file mode 100644 index 0000000000..24a1596681 --- /dev/null +++ b/frontend/editor/src/core/tests/stubbed/saveHelpers.ts @@ -0,0 +1,76 @@ +import { expect } from "@app/tests/helpers/stub-test-base"; +import type { Download, Page } from "@playwright/test"; + +/** Save helpers for the PDF text editor specs. */ + +// Click download and resolve with the resulting file. Plain save only applies +// the edit to the workbench; `expectRisk` states up front whether this edit +// drops unrepresentable characters. +export async function saveAndDownload( + page: Page, + expectRisk: boolean, +): Promise { + const confirm = page.getByTestId("pdf-editor-save-risk-confirm"); + + if (!expectRisk) { + const downloadPromise = page.waitForEvent("download"); + await page.getByTestId("pdf-editor-download").click(); + const download = await downloadPromise; + // The download landing already proves nothing gated the save; assert the + // modal never mounted so a new risk regression fails loudly right here. + await expect(confirm).toHaveCount(0); + return download; + } + + await page.getByTestId("pdf-editor-download").click(); + // Wait for the modal itself before arming the download listener. + await expect(confirm).toBeVisible({ timeout: 10_000 }); + const downloadPromise = page.waitForEvent("download"); + await confirm.click(); + return downloadPromise; +} + +/** Drain a download to a Buffer. */ +export async function downloadBytes(download: Download): Promise { + const stream = await download.createReadStream(); + const chunks: Buffer[] = []; + for await (const c of stream) chunks.push(c as Buffer); + return Buffer.concat(chunks); +} + +interface DocIdentityWindow { + __editor_store?: { + document: unknown; + state: { pages: { runs: unknown[] }[] }; + }; + __prev_document?: unknown; +} + +// Record the currently-loaded document so {@link waitForReopenedPage} can tell +// the reopened document apart from the one still on screen. +export async function stashCurrentDocument(page: Page): Promise { + await page.evaluate(() => { + const w = window as unknown as DocIdentityWindow; + w.__prev_document = w.__editor_store?.document; + }); +} + +/** Wait until a genuinely NEW document has loaded and populated `pageIndex`. */ +export async function waitForReopenedPage( + page: Page, + pageIndex: number, + timeout = 30_000, +): Promise { + await page.waitForFunction( + (idx: number) => { + const w = window as unknown as DocIdentityWindow; + const store = w.__editor_store; + if (!store?.document || store.document === w.__prev_document) { + return false; + } + return (store.state.pages[idx]?.runs.length ?? 0) > 0; + }, + pageIndex, + { timeout }, + ); +} diff --git a/frontend/editor/src/core/tests/test-fixtures/annotation-text-sample.pdf b/frontend/editor/src/core/tests/test-fixtures/annotation-text-sample.pdf new file mode 100644 index 0000000000000000000000000000000000000000..d222babc674f4845658e26734039bda560c60d65 GIT binary patch literal 2121 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 z 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 z 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 S;`JjTfXXVoixwGL82t~JN=3E+ literal 0 HcmV?d00001 diff --git a/frontend/editor/src/core/tests/test-fixtures/cropbox-control.pdf b/frontend/editor/src/core/tests/test-fixtures/cropbox-control.pdf new file mode 100644 index 0000000000..ca5fecb8bf --- /dev/null +++ b/frontend/editor/src/core/tests/test-fixtures/cropbox-control.pdf @@ -0,0 +1,33 @@ +%PDF-1.4 +% +1 0 obj +<< /Type /Catalog /Pages 2 0 R >> +endobj +2 0 obj +<< /Type /Pages /Kids [3 0 R] /Count 1 >> +endobj +3 0 obj +<< /Type /Page /Parent 2 0 R /MediaBox [ 0 0 400 400 ] /CropBox [ 0 0 400 400 ] /Rotate 0 /Resources << /Font << /F1 5 0 R >> >> /Contents 4 0 R >> +endobj +4 0 obj +<< /Length 33 >> +stream +BT /F1 24 Tf 60 350 Td (Hi) Tj ET +endstream +endobj +5 0 obj +<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >> +endobj +xref +0 6 +0000000000 65535 f +0000000015 00000 n +0000000064 00000 n +0000000121 00000 n +0000000284 00000 n +0000000367 00000 n +trailer +<< /Size 6 /Root 1 0 R >> +startxref +437 +%%EOF diff --git a/frontend/editor/src/core/tests/test-fixtures/cropbox-offset.pdf b/frontend/editor/src/core/tests/test-fixtures/cropbox-offset.pdf new file mode 100644 index 0000000000..a1d81f17c9 --- /dev/null +++ b/frontend/editor/src/core/tests/test-fixtures/cropbox-offset.pdf @@ -0,0 +1,33 @@ +%PDF-1.4 +% +1 0 obj +<< /Type /Catalog /Pages 2 0 R >> +endobj +2 0 obj +<< /Type /Pages /Kids [3 0 R] /Count 1 >> +endobj +3 0 obj +<< /Type /Page /Parent 2 0 R /MediaBox [ 0 0 400 400 ] /CropBox [ 50 30 350 380 ] /Rotate 0 /Resources << /Font << /F1 5 0 R >> >> /Contents 4 0 R >> +endobj +4 0 obj +<< /Length 33 >> +stream +BT /F1 24 Tf 60 350 Td (Hi) Tj ET +endstream +endobj +5 0 obj +<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >> +endobj +xref +0 6 +0000000000 65535 f +0000000015 00000 n +0000000064 00000 n +0000000121 00000 n +0000000286 00000 n +0000000369 00000 n +trailer +<< /Size 6 /Root 1 0 R >> +startxref +439 +%%EOF diff --git a/frontend/editor/src/core/tests/test-fixtures/cropbox-rotate90.pdf b/frontend/editor/src/core/tests/test-fixtures/cropbox-rotate90.pdf new file mode 100644 index 0000000000..dc9a5f534f --- /dev/null +++ b/frontend/editor/src/core/tests/test-fixtures/cropbox-rotate90.pdf @@ -0,0 +1,33 @@ +%PDF-1.4 +% +1 0 obj +<< /Type /Catalog /Pages 2 0 R >> +endobj +2 0 obj +<< /Type /Pages /Kids [3 0 R] /Count 1 >> +endobj +3 0 obj +<< /Type /Page /Parent 2 0 R /MediaBox [ 0 0 400 400 ] /CropBox [ 50 30 350 380 ] /Rotate 90 /Resources << /Font << /F1 5 0 R >> >> /Contents 4 0 R >> +endobj +4 0 obj +<< /Length 33 >> +stream +BT /F1 24 Tf 60 350 Td (Hi) Tj ET +endstream +endobj +5 0 obj +<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >> +endobj +xref +0 6 +0000000000 65535 f +0000000015 00000 n +0000000064 00000 n +0000000121 00000 n +0000000287 00000 n +0000000370 00000 n +trailer +<< /Size 6 /Root 1 0 R >> +startxref +440 +%%EOF diff --git a/frontend/editor/src/core/tests/test-fixtures/form-xobject-sample.pdf b/frontend/editor/src/core/tests/test-fixtures/form-xobject-sample.pdf new file mode 100644 index 0000000000000000000000000000000000000000..f2a2ace7f63f50d95cae1b9924fb2bafb5208668 GIT binary patch literal 2017 zcmY!laB(ZyZv;CY!o?eN2u_i|G@PxNaqQW5(%xTP5TyAh>y%DUMcT0Gy z@+_&lm+~IY^Z0ek{^duP2a7&T;lJ>C-nwSZMdDIosfHU2B=*OujuD={q^)R{-P7 z00>PC3>1uj7#M55i6upuAaPJs7(hv&cu;C_erZv1YOw++;@$FrQ43;tq~?^RmSiR; z>Y5u^T9{ZEm|IvXm;f~fAQ;IQKN;&JrYRKdEy|QZ^D5b=MQ%cMZP)Z38 zEGYz~l%3&@Gr7HPJG?w^8NbaXdRNYomHpe@t@hsAQKLN zxNu|lpcRWP&-y?*Dwr9(#!9k_ES=%DI+?Frlr{l7%^VhKe{A}u#WEta&OFflN^W2nJm zq9G`|5#t{;Be_9Fu49P7W+ZZ1jWviMr3J9C2rdDZ4t@$opuAI53`_9$vLJsKg#)5!~8 z$4vfmHahmTd*7R@Cyi6qXBqvUIXkU)XZxS*`P;;Q2uw@t63c6g_6!OR4O-aMcI*1w z1BbJgOZAB;k9hqz)f4MJ{``EGY1V_q2bP~U%rjBtZY;liQ;qe*y?J&yC7)Fa zz7$8ysb{V!tb2d{o%KYkI>xl7x)yAKh+Js}XI7;u7#ahM!~Fab1&|D|ob${}%U3Xl zuUP@X(t0CqbrO@CB&IIpK&`hrK(Lx5M>sWR7f(PkdU5`08C?t*b=dLrnn@rsHCC@ Q=nzA5OCv5-RabvE0A6O%jsO4v literal 0 HcmV?d00001 diff --git a/frontend/editor/src/core/tests/test-fixtures/generate-annotation-text-sample.mjs b/frontend/editor/src/core/tests/test-fixtures/generate-annotation-text-sample.mjs new file mode 100644 index 0000000000..f3201bdf5b --- /dev/null +++ b/frontend/editor/src/core/tests/test-fixtures/generate-annotation-text-sample.mjs @@ -0,0 +1,73 @@ +import process from "node:process"; +// One-off script: generate `annotation-text-sample.pdf`, a page that carries +// BOTH editable page text and text that only exists inside annotations +// (a FreeText annotation and a form-field widget). +// +// The editor renders with FPDF_ANNOT but walks page objects only, so the +// annotation text is visible and uneditable. This fixture lets the outline + +// tooltip affordance be regression-tested. +// +// Run with: node generate-annotation-text-sample.mjs +import { writeFileSync } from "node:fs"; +import { fileURLToPath } from "node:url"; +import { dirname, join } from "node:path"; +import { + PDFDocument, + StandardFonts, + rgb, + PDFName, + PDFString, +} from "@cantoo/pdf-lib"; + +const __dirname = dirname(fileURLToPath(import.meta.url)); + +async function main() { + const doc = await PDFDocument.create(); + const helv = await doc.embedFont(StandardFonts.Helvetica); + const page = doc.addPage([400, 300]); + + // Ordinary page text - this IS editable. + page.drawText("Editable page text", { + x: 30, + y: 250, + size: 16, + font: helv, + color: rgb(0, 0, 0), + }); + + // A form-field widget: annotation-backed, not page text. + const form = doc.getForm(); + const field = form.createTextField("sample.field"); + field.setText("Widget field text"); + field.addToPage(page, { + x: 30, + y: 180, + width: 220, + height: 24, + font: helv, + }); + + // A FreeText annotation: also annotation-backed, not page text. + const freeText = doc.context.obj({ + Type: PDFName.of("Annot"), + Subtype: PDFName.of("FreeText"), + Rect: [30, 110, 260, 140], + Contents: PDFString.of("FreeText annotation body"), + DA: PDFString.of("/Helv 12 Tf 0 g"), + F: 4, + }); + const ref = doc.context.register(freeText); + const annots = page.node.Annots(); + if (annots) annots.push(ref); + else page.node.set(PDFName.of("Annots"), doc.context.obj([ref])); + + const bytes = await doc.save(); + const out = join(__dirname, "annotation-text-sample.pdf"); + writeFileSync(out, bytes); + console.log(`wrote ${out} (${bytes.length} bytes)`); +} + +main().catch((err) => { + console.error(err); + process.exit(1); +}); diff --git a/frontend/editor/src/core/tests/test-fixtures/generate-big-sample.mjs b/frontend/editor/src/core/tests/test-fixtures/generate-big-sample.mjs new file mode 100644 index 0000000000..15e221236f --- /dev/null +++ b/frontend/editor/src/core/tests/test-fixtures/generate-big-sample.mjs @@ -0,0 +1,52 @@ +import process from "node:process"; +// One-off script: generate `big-sample.pdf`, an 80-page synthetic PDF +// with a few hundred text objects per page. Exercises the loading +// overlay (visible for several seconds on cold load) and the lazy +// page reader. Not heavy on disk (~ a few hundred KB) but heavy enough +// on parse + extract time to surface a UI freeze if one returns. +// +// Run with: node generate-big-sample.mjs +import { writeFileSync } from "node:fs"; +import { fileURLToPath } from "node:url"; +import { dirname, join } from "node:path"; +import { PDFDocument, StandardFonts, rgb } from "@cantoo/pdf-lib"; + +const __dirname = dirname(fileURLToPath(import.meta.url)); + +async function main() { + const doc = await PDFDocument.create(); + const font = await doc.embedFont(StandardFonts.Helvetica); + const PAGES = 80; + const LINES_PER_PAGE = 80; + for (let p = 0; p < PAGES; p++) { + const page = doc.addPage([612, 792]); + page.drawText(`Page ${p + 1} of ${PAGES}`, { + x: 50, + y: 740, + size: 22, + font, + color: rgb(0, 0, 0), + }); + for (let l = 0; l < LINES_PER_PAGE; l++) { + page.drawText( + `Line ${l + 1} on page ${p + 1}: sample body content for paragraph clustering.`, + { + x: 50, + y: 700 - l * 18, + size: 11, + font, + color: rgb(0, 0, 0), + }, + ); + } + } + const out = await doc.save(); + const target = join(__dirname, "big-sample.pdf"); + writeFileSync(target, out); + console.log(`wrote ${target} (${out.length} bytes, ${PAGES} pages)`); +} + +main().catch((err) => { + console.error(err); + process.exit(1); +}); diff --git a/frontend/editor/src/core/tests/test-fixtures/generate-cropbox-fixtures.py b/frontend/editor/src/core/tests/test-fixtures/generate-cropbox-fixtures.py new file mode 100644 index 0000000000..2d7cd2900c --- /dev/null +++ b/frontend/editor/src/core/tests/test-fixtures/generate-cropbox-fixtures.py @@ -0,0 +1,70 @@ +#!/usr/bin/env python3 +"""Generate minimal synthetic PDFs for the PDF text editor CropBox/rotation tests. + +These are hand-authored fixtures (NOT spirit-sx, which must never be committed). +Each has one page with a single Helvetica text object "Hi" at a known user-space +baseline, a MediaBox, and a CropBox whose origin is deliberately offset from the +MediaBox so the editor's display transform is exercised. Run from this dir: + + python generate-cropbox-fixtures.py +""" + + +def build_pdf(media, crop, rotate, text, tx, ty, font_size=24): + """Return bytes of a 1-page PDF. media/crop are [x0,y0,x1,y1]; text drawn + at Td(tx,ty) in user space with Helvetica.""" + objs = [] + objs.append(b"<< /Type /Catalog /Pages 2 0 R >>") + objs.append(b"<< /Type /Pages /Kids [3 0 R] /Count 1 >>") + page = ( + b"<< /Type /Page /Parent 2 0 R " + + b"/MediaBox [ %d %d %d %d ] " % tuple(media) + + b"/CropBox [ %d %d %d %d ] " % tuple(crop) + + b"/Rotate %d " % rotate + + b"/Resources << /Font << /F1 5 0 R >> >> " + + b"/Contents 4 0 R >>" + ) + objs.append(page) + stream = ( + b"BT /F1 %d Tf %d %d Td (%s) Tj ET" + % (font_size, tx, ty, text.encode("ascii")) + ) + objs.append(b"<< /Length %d >>\nstream\n" % len(stream) + stream + b"\nendstream") + objs.append(b"<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>") + + out = bytearray(b"%PDF-1.4\n%\xe2\xe3\xcf\xd3\n") + offsets = [0] + for i, body in enumerate(objs, start=1): + offsets.append(len(out)) + out += b"%d 0 obj\n" % i + body + b"\nendobj\n" + xref_pos = len(out) + out += b"xref\n0 %d\n" % (len(objs) + 1) + out += b"0000000000 65535 f \n" + for off in offsets[1:]: + out += b"%010d 00000 n \n" % off + out += ( + b"trailer\n<< /Size %d /Root 1 0 R >>\nstartxref\n%d\n%%%%EOF\n" + % (len(objs) + 1, xref_pos) + ) + return bytes(out) + + +def main(): + # (A) Control: CropBox == MediaBox, Rotate 0. Must behave like today. + open("cropbox-control.pdf", "wb").write( + build_pdf([0, 0, 400, 400], [0, 0, 400, 400], 0, "Hi", 60, 350) + ) + # (B) CropBox origin offset (50,30); visible page is 300x350 portrait. + # Text baseline user-space (60,350) -> display (10,320). + open("cropbox-offset.pdf", "wb").write( + build_pdf([0, 0, 400, 400], [50, 30, 350, 380], 0, "Hi", 60, 350) + ) + # (C) CropBox offset + Rotate 90. Displayed page swaps to 350x300. + open("cropbox-rotate90.pdf", "wb").write( + build_pdf([0, 0, 400, 400], [50, 30, 350, 380], 90, "Hi", 60, 350) + ) + print("wrote cropbox-control.pdf, cropbox-offset.pdf, cropbox-rotate90.pdf") + + +if __name__ == "__main__": + main() diff --git a/frontend/editor/src/core/tests/test-fixtures/generate-form-xobject-sample.mjs b/frontend/editor/src/core/tests/test-fixtures/generate-form-xobject-sample.mjs new file mode 100644 index 0000000000..e95740ff2b --- /dev/null +++ b/frontend/editor/src/core/tests/test-fixtures/generate-form-xobject-sample.mjs @@ -0,0 +1,68 @@ +import process from "node:process"; +// One-off script: generate `form-xobject-sample.pdf`, a synthetic +// magazine-style PDF whose page content lives inside a Form XObject. +// This mirrors the structural pattern InDesign / professional layout +// tools emit (e.g. PC Magazin issues) so the editor's recursive text +// extractor can be regression-tested without shipping a copyrighted +// binary fixture. +// +// Run with: node generate-form-xobject-sample.mjs +// +// The output is checked into test-fixtures/ and consumed by +// pdf-text-editor.spec.ts under the "form xobject recursion" group. +import { writeFileSync } from "node:fs"; +import { fileURLToPath } from "node:url"; +import { dirname, join } from "node:path"; +import { PDFDocument, StandardFonts, rgb } from "@cantoo/pdf-lib"; + +const __dirname = dirname(fileURLToPath(import.meta.url)); + +async function main() { + const srcDoc = await PDFDocument.create(); + const helv = await srcDoc.embedFont(StandardFonts.Helvetica); + const srcPage = srcDoc.addPage([400, 200]); + srcPage.drawText("Magazine cover title", { + x: 30, + y: 150, + size: 24, + font: helv, + color: rgb(0, 0, 0), + }); + srcPage.drawText("Subheading line below", { + x: 30, + y: 110, + size: 14, + font: helv, + color: rgb(0.2, 0.2, 0.2), + }); + srcPage.drawText("Inner body paragraph one.", { + x: 30, + y: 80, + size: 11, + font: helv, + color: rgb(0, 0, 0), + }); + srcPage.drawText("Inner body paragraph two.", { + x: 30, + y: 60, + size: 11, + font: helv, + color: rgb(0, 0, 0), + }); + const srcBytes = await srcDoc.save(); + + const dstDoc = await PDFDocument.create(); + const [embedded] = await dstDoc.embedPdf(srcBytes); + const dstPage = dstDoc.addPage([400, 200]); + dstPage.drawPage(embedded, { x: 0, y: 0, width: 400, height: 200 }); + const out = await dstDoc.save(); + + const target = join(__dirname, "form-xobject-sample.pdf"); + writeFileSync(target, out); + console.log(`wrote ${target} (${out.length} bytes)`); +} + +main().catch((err) => { + console.error(err); + process.exit(1); +}); diff --git a/frontend/editor/src/core/tests/test-fixtures/generate-many-pages-sample.mjs b/frontend/editor/src/core/tests/test-fixtures/generate-many-pages-sample.mjs new file mode 100644 index 0000000000..3200012f5a --- /dev/null +++ b/frontend/editor/src/core/tests/test-fixtures/generate-many-pages-sample.mjs @@ -0,0 +1,40 @@ +// One-off script: generate `many-pages-sample.pdf`, an 8-page PDF with two +// text lines per page. Eight is deliberately past the editor's +// EAGER_PAGE_LIMIT (5), so pages 6-8 carry no runs until something reads +// them - which is what makes it a fixture for "select all misses part of +// the document". Small enough to load in a test without a timeout. +// +// Run with: node generate-many-pages-sample.mjs +import { writeFileSync } from "node:fs"; +import { fileURLToPath } from "node:url"; +import { dirname, join } from "node:path"; +import { PDFDocument, StandardFonts, rgb } from "@cantoo/pdf-lib"; + +const __dirname = dirname(fileURLToPath(import.meta.url)); + +const PAGES = 8; +const LINES_PER_PAGE = 2; + +async function main() { + const doc = await PDFDocument.create(); + const font = await doc.embedFont(StandardFonts.Helvetica); + for (let p = 0; p < PAGES; p++) { + const page = doc.addPage([612, 792]); + for (let l = 0; l < LINES_PER_PAGE; l++) { + // Unique per line so a test can assert exactly which lines were hit. + page.drawText(`Page ${p + 1} line ${l + 1}`, { + x: 72, + y: 700 - l * 40, + size: 18, + font, + color: rgb(0, 0, 0), + }); + } + } + const bytes = await doc.save(); + const out = join(__dirname, "many-pages-sample.pdf"); + writeFileSync(out, bytes); + console.log(`wrote ${out} (${bytes.length} bytes, ${PAGES} pages)`); +} + +main(); diff --git a/frontend/editor/src/core/tests/test-fixtures/generate-paragraph-sample.mjs b/frontend/editor/src/core/tests/test-fixtures/generate-paragraph-sample.mjs new file mode 100644 index 0000000000..9beabbfa1e --- /dev/null +++ b/frontend/editor/src/core/tests/test-fixtures/generate-paragraph-sample.mjs @@ -0,0 +1,55 @@ +import process from "node:process"; +// One-off script: generate `paragraph-sample.pdf`, a synthetic PDF +// whose page contains a multi-line body paragraph with consistent +// font/size/colour/left-margin. ParagraphGrouper should fold all four +// lines into one editable block. +// +// Run with: node generate-paragraph-sample.mjs +import { writeFileSync } from "node:fs"; +import { fileURLToPath } from "node:url"; +import { dirname, join } from "node:path"; +import { PDFDocument, StandardFonts, rgb } from "@cantoo/pdf-lib"; + +const __dirname = dirname(fileURLToPath(import.meta.url)); + +async function main() { + const doc = await PDFDocument.create(); + const font = await doc.embedFont(StandardFonts.Helvetica); + const page = doc.addPage([400, 300]); + const left = 30; + const lineHeight = 16; + let y = 260; + page.drawText("Heading in a bigger size", { + x: left, + y, + size: 18, + font, + color: rgb(0, 0, 0), + }); + y -= 36; + const bodyLines = [ + "First line of the body paragraph that we want grouped together.", + "Second line continues the paragraph and shares the same font and", + "left margin so the grouper recognises it as part of the block.", + "Fourth line wraps the paragraph at the bottom of the column.", + ]; + for (const text of bodyLines) { + page.drawText(text, { + x: left, + y, + size: 11, + font, + color: rgb(0, 0, 0), + }); + y -= lineHeight; + } + const out = await doc.save(); + const target = join(__dirname, "paragraph-sample.pdf"); + writeFileSync(target, out); + console.log(`wrote ${target} (${out.length} bytes)`); +} + +main().catch((err) => { + console.error(err); + process.exit(1); +}); diff --git a/frontend/editor/src/core/tests/test-fixtures/generate-rotated-text-sample.py b/frontend/editor/src/core/tests/test-fixtures/generate-rotated-text-sample.py new file mode 100644 index 0000000000..890bf17ae1 --- /dev/null +++ b/frontend/editor/src/core/tests/test-fixtures/generate-rotated-text-sample.py @@ -0,0 +1,54 @@ +#!/usr/bin/env python3 +"""Generate rotated-text-sample.pdf: one page with a single text object whose +text matrix is rotated 30 degrees (an OBJECT rotation, not a page /Rotate). + +Used to verify the editor preserves a run's rotation when it re-emits the text +on edit (instead of forcing it upright). +""" +import math + + +def build() -> bytes: + cos = math.cos(math.radians(30)) + sin = math.sin(math.radians(30)) + stream = ( + f"BT /F1 24 Tf {cos:.5f} {sin:.5f} {-sin:.5f} {cos:.5f} 200 400 Tm " + f"(Rotated) Tj ET" + ).encode("ascii") + + objs: list[bytes] = [] + objs.append(b"<< /Type /Catalog /Pages 2 0 R >>") + objs.append(b"<< /Type /Pages /Kids [3 0 R] /Count 1 >>") + objs.append( + b"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] " + b"/Resources << /Font << /F1 5 0 R >> >> /Contents 4 0 R >>" + ) + objs.append( + b"<< /Length " + str(len(stream)).encode() + b" >>\nstream\n" + stream + b"\nendstream" + ) + objs.append( + b"<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica /Encoding /WinAnsiEncoding >>" + ) + + out = bytearray(b"%PDF-1.7\n%\xe2\xe3\xcf\xd3\n") + offsets: list[int] = [] + for i, body in enumerate(objs, start=1): + offsets.append(len(out)) + out += str(i).encode() + b" 0 obj\n" + body + b"\nendobj\n" + xref_pos = len(out) + n = len(objs) + 1 + out += b"xref\n0 " + str(n).encode() + b"\n0000000000 65535 f \n" + for off in offsets: + out += ("%010d 00000 n \n" % off).encode() + out += ( + b"trailer\n<< /Size " + str(n).encode() + b" /Root 1 0 R >>\n" + b"startxref\n" + str(xref_pos).encode() + b"\n%%EOF" + ) + return bytes(out) + + +if __name__ == "__main__": + data = build() + with open("rotated-text-sample.pdf", "wb") as f: + f.write(data) + print(f"wrote rotated-text-sample.pdf ({len(data)} bytes)") diff --git a/frontend/editor/src/core/tests/test-fixtures/generate-shading-and-justified-sample.mjs b/frontend/editor/src/core/tests/test-fixtures/generate-shading-and-justified-sample.mjs new file mode 100644 index 0000000000..ee2e6b3438 --- /dev/null +++ b/frontend/editor/src/core/tests/test-fixtures/generate-shading-and-justified-sample.mjs @@ -0,0 +1,111 @@ +import process from "node:process"; +// One-off script: generate two probe fixtures. +// +// shading-sample.pdf - an axial gradient painted with the `sh` operator plus +// a pattern-filled rectangle, with ordinary text over +// both. Probes whether editing the text costs the page +// its background artwork. +// justified-sample.pdf - text laid out with TJ arrays carrying inter-word +// offsets, the way justified copy is really emitted. +// Probes whether the reader invents extra spaces. +// +// Run with: node generate-shading-and-justified-sample.mjs +import { writeFileSync } from "node:fs"; +import { fileURLToPath } from "node:url"; +import { dirname, join } from "node:path"; +import { + PDFDocument, + PDFName, + PDFRawStream, + StandardFonts, +} from "@cantoo/pdf-lib"; + +const __dirname = dirname(fileURLToPath(import.meta.url)); + +function rawStream(doc, body) { + const bytes = new TextEncoder().encode(body); + return doc.context.register( + PDFRawStream.of(doc.context.obj({ Length: bytes.length }), bytes), + ); +} + +async function makeShading() { + const doc = await PDFDocument.create(); + const helv = await doc.embedFont(StandardFonts.Helvetica); + const page = doc.addPage([420, 300]); + const fontKey = page.node.newFontDictionaryKey("Helv"); + page.node.setFontDictionary(fontKey, helv.ref); + + // Axial shading, white -> mid grey across the page. + const fn = doc.context.obj({ + FunctionType: 2, + Domain: [0, 1], + C0: [0.95, 0.95, 1], + C1: [0.35, 0.55, 0.85], + N: 1, + }); + const shading = doc.context.obj({ + ShadingType: 2, + ColorSpace: PDFName.of("DeviceRGB"), + Coords: [0, 0, 420, 0], + Function: doc.context.register(fn), + Extend: [true, true], + }); + const shadingRef = doc.context.register(shading); + const resources = page.node.Resources(); + resources.set(PDFName.of("Shading"), doc.context.obj({ Sh0: shadingRef })); + + const body = [ + "q", + "0 0 420 300 re W n", + "/Sh0 sh", + "Q", + "BT /Helv 20 Tf 0 0 0 rg 34 210 Td (Text over a gradient) Tj ET", + "BT /Helv 14 Tf 34 170 Td (Second line of body text) Tj ET", + "", + ].join("\n"); + page.node.set(PDFName.of("Contents"), rawStream(doc, body)); + + const bytes = await doc.save(); + const out = join(__dirname, "shading-sample.pdf"); + writeFileSync(out, bytes); + console.log(`wrote ${out} (${bytes.length} bytes)`); +} + +async function makeJustified() { + const doc = await PDFDocument.create(); + const helv = await doc.embedFont(StandardFonts.Helvetica); + const page = doc.addPage([420, 220]); + const fontKey = page.node.newFontDictionaryKey("Helv"); + page.node.setFontDictionary(fontKey, helv.ref); + + // Justified copy: each inter-word gap is widened by a negative TJ number + // rather than by a wider space glyph, which is what real justification emits. + const line = (y, words, kern) => + `BT /Helv 13 Tf 30 ${y} Td [${words + .map((w, i) => `(${w})${i < words.length - 1 ? ` ${kern}` : ""}`) + .join(" ")}] TJ ET`; + + const body = [ + line(170, ["Justified", "copy", "spreads", "its", "words"], -420), + line(145, ["across", "the", "measure", "using", "offsets"], -560), + line(120, ["not", "by", "padding", "with", "spaces"], -300), + "", + ].join("\n"); + page.node.set(PDFName.of("Contents"), rawStream(doc, body)); + + const bytes = await doc.save(); + const out = join(__dirname, "justified-sample.pdf"); + writeFileSync(out, bytes); + console.log(`wrote ${out} (${bytes.length} bytes)`); +} + +async function main() { + await makeShading(); + await makeJustified(); +} + +main().catch((err) => { + console.error(err); + process.exit(1); +}); diff --git a/frontend/editor/src/core/tests/test-fixtures/generate-signed-sample.py b/frontend/editor/src/core/tests/test-fixtures/generate-signed-sample.py new file mode 100644 index 0000000000..a2528353c9 --- /dev/null +++ b/frontend/editor/src/core/tests/test-fixtures/generate-signed-sample.py @@ -0,0 +1,67 @@ +#!/usr/bin/env python3 +"""Generate signed-sample.pdf: a 1-page PDF carrying a digital-signature field. + +PDFium's FPDF_GetSignatureCount counts AcroForm fields of /FT /Sig that have a +/V signature dictionary, so the editor's pre-save warning can flag it. The +signature bytes are a placeholder - the point is detection, not validity. +""" +import struct # noqa: F401 (kept for parity with sibling generators) + + +def build() -> bytes: + objs: list[bytes] = [] + + # 1: Catalog with an AcroForm referencing the signature field. + objs.append( + b"<< /Type /Catalog /Pages 2 0 R " + b"/AcroForm << /Fields [5 0 R] /SigFlags 3 >> >>" + ) + # 2: Pages + objs.append(b"<< /Type /Pages /Kids [3 0 R] /Count 1 >>") + # 3: Page (the widget annotation is the signature field itself) + objs.append( + b"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] " + b"/Resources << >> /Contents 4 0 R /Annots [5 0 R] >>" + ) + # 4: empty content stream + stream = b"BT /F1 12 Tf 72 720 Td (Signed sample) Tj ET" + objs.append( + b"<< /Length " + str(len(stream)).encode() + b" >>\nstream\n" + stream + b"\nendstream" + ) + # 5: signature field + widget annotation + objs.append( + b"<< /FT /Sig /Type /Annot /Subtype /Widget /T (Signature1) " + b"/Rect [72 700 272 740] /P 3 0 R /V 6 0 R /F 132 >>" + ) + # 6: signature dictionary + objs.append( + b"<< /Type /Sig /Filter /Adobe.PPKLite /SubFilter /adbe.pkcs7.detached " + b"/Name (Test Signer) /M (D:20260101000000Z) " + b"/ByteRange [0 0 0 0] /Contents <0000> >>" + ) + + header = b"%PDF-1.6\n%\xe2\xe3\xcf\xd3\n" + out = bytearray(header) + offsets: list[int] = [] + for i, body in enumerate(objs, start=1): + offsets.append(len(out)) + out += str(i).encode() + b" 0 obj\n" + body + b"\nendobj\n" + + xref_pos = len(out) + n = len(objs) + 1 + out += b"xref\n0 " + str(n).encode() + b"\n" + out += b"0000000000 65535 f \n" + for off in offsets: + out += ("%010d 00000 n \n" % off).encode() + out += ( + b"trailer\n<< /Size " + str(n).encode() + b" /Root 1 0 R >>\n" + b"startxref\n" + str(xref_pos).encode() + b"\n%%EOF" + ) + return bytes(out) + + +if __name__ == "__main__": + data = build() + with open("signed-sample.pdf", "wb") as f: + f.write(data) + print(f"wrote signed-sample.pdf ({len(data)} bytes)") diff --git a/frontend/editor/src/core/tests/test-fixtures/generate-split-contents-sample.mjs b/frontend/editor/src/core/tests/test-fixtures/generate-split-contents-sample.mjs new file mode 100644 index 0000000000..2e99b1754f --- /dev/null +++ b/frontend/editor/src/core/tests/test-fixtures/generate-split-contents-sample.mjs @@ -0,0 +1,57 @@ +import process from "node:process"; +// One-off script: generate `split-contents-sample.pdf`, a page whose /Contents +// is an ARRAY of streams split MID-OPERATOR - no member is independently valid +// PDF content, only their concatenation is. Acrobat Distiller emits this shape +// when a page's content exceeds its internal buffer. +// +// The spec defines a /Contents array as the concatenation of its members, so a +// reader must join them before tokenizing. The risk being probed is that an +// editor which regenerates only the member owning a dirty object turns the +// concatenation into operator soup. +// +// Run with: node generate-split-contents-sample.mjs +import { writeFileSync } from "node:fs"; +import { fileURLToPath } from "node:url"; +import { dirname, join } from "node:path"; +import { + PDFDocument, + PDFName, + PDFRawStream, + StandardFonts, +} from "@cantoo/pdf-lib"; + +const __dirname = dirname(fileURLToPath(import.meta.url)); + +async function main() { + const doc = await PDFDocument.create(); + const helv = await doc.embedFont(StandardFonts.Helvetica); + const page = doc.addPage([420, 260]); + const fontKey = page.node.newFontDictionaryKey("Helv"); + page.node.setFontDictionary(fontKey, helv.ref); + + // Deliberately cut each operator sequence across the member boundary. + const pieces = [ + "BT /Helv 18 Tf 40 200 Td (Split contents line one) Tj ET\nBT /Helv 18 Tf 40 1", + "70 Td (Split contents line two) Tj ET\nBT /Helv 18 Tf 40 140 Td (Split cont", + "ents line three) Tj ET\n", + ]; + + const refs = pieces.map((body) => { + const stream = PDFRawStream.of( + doc.context.obj({ Length: body.length }), + new TextEncoder().encode(body), + ); + return doc.context.register(stream); + }); + page.node.set(PDFName.of("Contents"), doc.context.obj(refs)); + + const bytes = await doc.save(); + const out = join(__dirname, "split-contents-sample.pdf"); + writeFileSync(out, bytes); + console.log(`wrote ${out} (${bytes.length} bytes, ${refs.length} members)`); +} + +main().catch((err) => { + console.error(err); + process.exit(1); +}); diff --git a/frontend/editor/src/core/tests/test-fixtures/generate-subset-font-sample.py b/frontend/editor/src/core/tests/test-fixtures/generate-subset-font-sample.py new file mode 100644 index 0000000000..35e4c68e1a --- /dev/null +++ b/frontend/editor/src/core/tests/test-fixtures/generate-subset-font-sample.py @@ -0,0 +1,77 @@ +#!/usr/bin/env python3 +"""One-off generator for `subset-font-sample.pdf`. + +This fixture exercises the PDF text editor's subset-font fallback branch +(`canReuseFont = ... && !run.fontSubset`). The editor flags a run as a +subset font only when PDFium's FPDFFont_GetFamilyName returns a name +matching /^[A-Z]{6}\\+/ - and PDFium reads that name from the embedded +font program's `name` table, NOT the PDF BaseFont entry. Plain pdf-lib / +fontTools subsetting only tags the BaseFont, so we must also rewrite the +embedded font's name table to carry the 6-letter "ABCDEF+" subset tag. + +Run with: pip install pymupdf fonttools && python generate-subset-font-sample.py +Source font: @embedpdf/fonts-latin NotoSans-Regular.ttf (already a repo dep). +""" +import os +from fontTools.ttLib import TTFont +from fontTools.subset import Subsetter, Options +import fitz # PyMuPDF + +HERE = os.path.dirname(os.path.abspath(__file__)) +SRC_FONT = os.path.join( + HERE, + "../../../../../node_modules/@embedpdf/fonts-latin/fonts/NotoSans-Regular.ttf", +) +OUT = os.path.join(HERE, "subset-font-sample.pdf") +TAG = "ABCDEF+" + +LINES = [ + "Subset font sample line one", + "Body text with embedded subset glyphs", + "Editing this run must fall back cleanly", +] + + +def make_named_subset(tmp_path: str) -> None: + font = TTFont(SRC_FONT) + opt = Options() + opt.name_IDs = ["*"] + ss = Subsetter(options=opt) + ss.populate(text="".join(LINES)) + ss.subset(font) + # Stamp the subset tag into the font program's own name table so PDFium + # surfaces it via FPDFFont_GetFamilyName (Windows 3,1 + Mac 1,0 records). + name = font["name"] + for pid, eid, lid in [(3, 1, 0x409), (1, 0, 0)]: + name.setName(TAG + "NotoSubset", 1, pid, eid, lid) # family + name.setName("Regular", 2, pid, eid, lid) # subfamily + name.setName(TAG + "NotoSubset", 4, pid, eid, lid) # full + name.setName(TAG + "NotoSubset", 6, pid, eid, lid) # postscript + font.save(tmp_path) + font.close() + + +def main() -> None: + tmp = os.path.join(HERE, "_noto-subset-named.ttf") + make_named_subset(tmp) + try: + doc = fitz.open() + page = doc.new_page(width=420, height=220) + # set_simple=True -> simple (non-CID) TrueType, so PDFium reports the + # name-table family verbatim (CID fonts get the tag stripped). + page.insert_font(fontname="NS", fontfile=tmp, set_simple=True) + y = 70 + for line in LINES: + page.insert_text((36, y), line, fontname="NS", fontsize=14) + y += 30 + # Do NOT call doc.subset_fonts(): the font is already subset + renamed. + doc.save(OUT, garbage=4, deflate=True) + doc.close() + print(f"wrote {OUT} ({os.path.getsize(OUT)} bytes)") + finally: + if os.path.exists(tmp): + os.remove(tmp) + + +if __name__ == "__main__": + main() diff --git a/frontend/editor/src/core/tests/test-fixtures/justified-sample.pdf b/frontend/editor/src/core/tests/test-fixtures/justified-sample.pdf new file mode 100644 index 0000000000000000000000000000000000000000..aba390a9c6ff6aa915dbad223187eeee2b2c3b72 GIT binary patch literal 1178 zcmaJ=Z%9*76t}Q{7eDx+50X^3$Sh>Nx3|r05Q8+AnoHYO3L^R3yPHqkd+XghHxq5^ zLlRVEgiuKJp#;S!vPeQ{q5Z@DKvt0b!4FXsMV22DlIq^AXM)lmgLBTk=XZYRchAYG z*zPnKw^%_=oc;hXLLRDh4Pfh5z8(}r9`4EPTl~biB%qT{UO$IA( zx0!9c)n zO2e4Qei=!dp@9|NxQwc1oSi2@5hK1`wu>)n&eOQp;;!Lg4Z$LNh>5uQVK5VMr2a2AK{Ql zA8!_!&C6erbTzGW)O5;?1Zj|SHqsZ{j(nLEeE?ScZG16jj#M0mjI`U`XQ zk9IxZ?bckzmnJ-GbPp@~bcmaZsX|E6!^R^(~l+mZhEd+#2# zOcgc2iu0?M)Z|Wz$3~l*@<`LQ&ik1idox@cmK|Sq_xQTg{Tr*>Oh<3VaQDXEAAK(u z2TNTqtSatVYrk37MKpwT?T9v^4xOJ+=UOOhk=DHCozk literal 0 HcmV?d00001 diff --git a/frontend/editor/src/core/tests/test-fixtures/letter-spacing-sample.pdf b/frontend/editor/src/core/tests/test-fixtures/letter-spacing-sample.pdf new file mode 100644 index 0000000000..53a99da3cf --- /dev/null +++ b/frontend/editor/src/core/tests/test-fixtures/letter-spacing-sample.pdf @@ -0,0 +1,43 @@ +%PDF-1.4 +1 0 obj +<< /Type /Catalog /Pages 2 0 R >> +endobj +2 0 obj +<< /Type /Pages /Kids [3 0 R] /Count 1 >> +endobj +3 0 obj +<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] /Resources << /Font << /F1 4 0 R >> >> /Contents 5 0 R >> +endobj +4 0 obj +<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >> +endobj +5 0 obj +<< /Length 116 >> +stream +BT +/F1 18 Tf +2 Tc +72 700 Td +(SPACED HEADING) Tj +ET +BT +/F1 12 Tf +0 Tc +72 650 Td +(Normal body line for contrast) Tj +ET +endstream +endobj +xref +0 6 +0000000000 65535 f +0000000009 00000 n +0000000058 00000 n +0000000115 00000 n +0000000241 00000 n +0000000311 00000 n +trailer +<< /Size 6 /Root 1 0 R >> +startxref +478 +%%EOF diff --git a/frontend/editor/src/core/tests/test-fixtures/many-pages-sample.pdf b/frontend/editor/src/core/tests/test-fixtures/many-pages-sample.pdf new file mode 100644 index 0000000000000000000000000000000000000000..4a55f2efbbc6add1230c951e036d4f89e13f745d GIT binary patch literal 4135 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

{Nm283 zW8CJGhI=$;%zo@-Du&9ie-K#OYfI1&v_C4_#QL^ai8rLcAhF9bS`WHk>K=;aAW>1b zUZ_3RXRi1F`r1-w`@vrhK2re;f-|Suh&cT9DGB2_oI$uxU4AynENs1^Sbg2yQ)0~F zwbMQOM-W*llV62PaQr3|%*4#{v(K0#Odqi1+oXD-S$W=lz(oK@c05UEy|!q#AH%r8 zM^z)KL6a7aP3TUxB+Qx`BEAp{yY}E|@=ADguVq_*X@1E*;-!at7aE31OB{M_Qre!$ zZJVr;1MbA2+5IlJdVLcW#ijg~10SqYU*-xqk^Cy|-KWec3YK-l&3H8MWD_~rOvZ`U zy@wcL!RW8S@f@Oz#uRL#%c+zo`tc6y!cgJm!VIJfTO07{3tg#i3Jtv~C32Je7O9$5 zo%r4_l0iEa`qSY|F73!IV*A1q z*A|($O_-(EpYa9RqS?Zbaw>*SqCZ85L+i&h9PdHq)V(KM@veo>)_5On zp+FV81iU_b66>47D`$Ffo?MAD-Q3iRU|$fHNIT>ic+U@cSk|iIqPjc4Q-Mf<5j67J`LErGi7RzbVK&29zfwm+l`n zbc0M_S6DLDTMN?=_}XfeHlgpNMFn@QZI{+q#t9mTO*@xi)GJgw!P_&Nc1y#4$PBUz zMyjT;wL6-BZwj=CKRO+@6wzhUd)F{!RK)S3pIrp?PNrN$tXlzTt#L$ug#qqY+#&y0xwz zJ8MB)K%*j@8MMr6XV1f)~S9yD~j$VeVFhszttN zqa)q9C}ZeC8Pw;2F&n5wg1t0y)9DRo<8eC|v^hWJZ=8}VnRYp@v?{Bu;%W{C9d6Ry zdI4LnKp=MnM}J`E?XY?R;4A#>?|m0 zFm`N>vBIHmE|&M{*j`zvsS-Y^d6LJHapUVWo8r|#q@kcWHGOQ3m^^}Si{HI3uWG$6 zXGiNVhozYrp;l0u^t&ZKuz>WGNTBH!I+BaCMT6V@)15cx|^66o>e#l&qB1DV9Hzue0 zqZ8asc{_`~s7?z+5@(!cNmmr3sF7!hE929+R6Zk<%L;4QUwF2J)^*MrG=WQaU7vhQ zQ8S4dGlqz+DzBq2jd-0>5PS2CbqU`T^f9tJs_r%x!&;OzS05^>qWYbTZ!}bc4)K&x z*oKdX)lljvnTOB%DrnqOXt3w@^Bqp~6Dk)J*S=GKa*f5uwTSS{ww1eUJ%P;&%;o%QviVH}k%{>y+rllH!%|ZK zR`@ILvjKQA=p;&y9deqE$+KG7`XYQ1BI^xx)Uc<04SpWvf?lCg*3<<7m6@CXlq}qB z4L5dK&rKjToFc1;2J}&xNG}|gY<4Pms2PJZHJpfz>EtV1$H1NlGY=Bk3fuOQt`j-` zafTVOb(kv@rADtbDkL*Y)b|xA4^sr7BYUpQT2>${g!`=nd#@hw6-QzffkOmaO=>VS#yh}UP^ws!{%&kmWQ7Rm{+#Ncb(1H(K_GQ$w~T~ zS%t+RnZNluZQ)mwW1T(Iq56R44eMC!PK|YD&cY|;(6sd}R2?>)KSVQ0-(%@Gx@k(_ zWG&5p6^ecF(uuZQH?|)31^0nA3McjCrme0|xwK+PHs-gUkyy(RpH*2ECUv-El2MlA zs2y90Y<)3v)wx~DJ6i=XK7VB{y*K6$tk|qkN;q_r{&^p==xxj53_{z-GFu=J!Og%N zWWC|Nt=Sn1W#%s!bsty!Y-*@fckOkjHA#-AR;U9?XI`4GD6*k<@GK6uu6eiRfdyE- zYJBqd#nq{~Y6D3erN=9VE3wCT$~$ww9^0t4VxRUPXqbH)(pU4MXbF*P1XecD;X*gD zdZAa1s>knx*Lp>?kkwEt8~Ifd1P*q3eDsUcG$9Rf;eAk%TdLVn#ryT1w8PQcmPa7& z_Abg_jm_WA2LB1&S03Sl7X<9o#(9g+z|*z^`_hjf2!cL6z2XrTSlBF%^kmMRRG04D z_M$;+m&8Ya{ZQ2RNo zh@DxFyZiY4&#KS54iJ;kh1*mTq%`U^Yzi%cwZfkfmi4nF(GDZh8x!D+asq1vngk!I z!R2>m(j(%|W+*F2)%C;WAS#M4E43DtaD$T}t7I!w(21n0kxQ0$W8g$1vTEcVYL&C~ zOSUU`3v2Z|r?&J>sg!tbWh=EvIlH^zgX}$7$2(KktZmeKgQE+U%HB_vMC>A$3QDU784?}Yi)Cb~zit{{+xsBT6avrGQ)pEjVJd7b zZf#>gEaa-agE!bxaf-hc?N&m5am?eQ#GRSglG8Fd)vMN@iEX9hgh%D|X+A%xwW2R= zJ8tdr2~2GN9g|MJEpaQ9Jc;~GTNGo~X`oW`&WwtZ&&a?==^$)QdQsICcW`_T5qFcX z$|q9?rxFwIRZS~a+jluW92YAY6^M3>yRqdOEO^Z6`?jg>u_8y@9K269Z(M`#u8hy$ zw9S@ZqLH%ZZv3hv{-&3mk^N`6smMPk{zB6vi$oj|%R&oAd}eSQ$2-`05yor}RznT& zLoK37k^0h#x_a+)B(o{a01|)kFS^2uI`}#DYx+!^U3|CvS}G#8g|Sd)Xj{!paOBkD zhq=M9%@#x3IS4M0U6{c5qVub8-`4}(p0GIUyL_>Y9R@eCaX5j1sP?2s^fEc}`20Qd z7YWmFQ9T=ubZD{I{E@WP$e%deEpDkmesfFRx3z``*jO4-TN!xlz}9Spo z0MfW50@zW7lossFHT{~<(Bp28oM0z*6gtK-pZJrFy^lof2TiOPj73y$t_#ypuEC0x3_FoW2!N37X z?tKn_5EOjxh_3;lwEic;D8BOw0?@I4FpT0yQ0Vu8dIms}4FDmcf1X$V@&brwa?r9d z0%SJ+a_r}KfHRnB0oeY(C&=K>-wg;hV5Vhb{hlHG$1!$h0P`*b!*{6b&u9Iu|A5n; zBOh29o{jOp|6&KkNU#Ga9{*NQ#s9hi{{Hv>`BMf4K=U#)F@Kjb`SZcQ^&cQ>LQl&I zkWXRxcg0FD0d!OT^Uz@?hQCK<&&2r82rx1+iu@U&puRDsjzKv$fY|JCm^E7^skQ02R(;(@5R=t@06vgii0W{K!y1uRe&pz=`?*> zWhXL3Fl*~)_-qkLyigjcHQNyEK&YTJjEOzjlX7Gs*D-xf92ysq1L$Ea!xoXwjAzI>c zz2qGGc;L_&RefGFev zOHX%i7lb^1ORD5koi-CKhf3VZ$%`7XVn9IX$bvd6kD4K!s;_o|UjD(4RB}cQVDSnT zyX%d^Clk=k?!#+YhPTp#O6^N-sY7@|i!KEK0WenX0}uS=C( zppxMUY2QG?oh?h1leS*;k-~HVtwW9+r*5KMo{JwWV^zqfyM29+YX7A}aEV71WozBNi6{vB6wEk>z81Qh+6RyzxsKU0#8I09qHv9H9F8u+r7caS^2s|SO>| z!Ho+wxVIE0gYTbzZR2?i9ckq-975plS+!u_2U_0M>1o299|BZk&ao35xol9s^VJy~ zWQA80v-|+^y6_mL_wmx@(k)EKUw_Hm#J;Lo9ESQz^+T_8_+C=v53N2xlvDKwxoQWVPPs|+D zV3U`+#adIR>4TTlHgRO+}@Ia@Apg;yC958}>tZ5aIE( z^jz+nrO6d=R-}XM1JZHfI%J)`1T8BBRLlaxb3#I=v`bo-nLRelvL+dvb%YgF)uH(_ z4)HAxsVB5kx62fu)q1I;V_8A?%yGDykGdnw8@$a^nd$LnrSq#isW7|iG-2Znkdz4PjFsxe>y zwSy--M7D4_(8+VbX$x}?e|mvc?)u0Yt{Cp325xsf5C9 zGejvvU%{&{()!YDGCB37V{~e@7-gn;eiNg6NI4C`1zfuOa{ZpQ#LU}TW&&g_GJz$_ zD1saJCM|}LlebLB@!DQ{jgJ%cchyB^mH9YzJ0VKy_ePnx2tK0-xd}x@GYB^bztURy zk@EV#z~bciDQ^E+`}-e`+yCR)0yZQ6puzdK%$Wb_#{sNP04^a$fSLI3GPC~G&F}xa z{vR_w019Eh-r|4XH9v2wpEbn(*irtpt!4ozh5p_qllf%J}2H4LsbEY`^WZ45Z1k=#Ixhcs+AinbEu%Wlxt*&b4VFEmC|m%R|Wg+b#b z=RKX$9L9B5od5`)X-an0Ly*xojk#e?=u+=Ln_|W4bNhQb*{gu1_Mh`8sJHaVI#*_G z7A8E>_e9jvv#*w{e4*tFa)0gitGW3%v28|{pW+Rb0mAh?_^`(S;d&47Y8zZ*Mv9Lo zt44^uq_HILoPyE4Gk~o9F_G40GCw$6AM}(8OzSFLFH4B|j;7LQdP(6kjXDLkTN;7O zLMs!?ty8XWjKcSC!&b?IHMhsFo3%xY0EKB02cKo2<~S8`cHr2JG|8&(YdWEP;uEb*TAOqNx8p~2 zX#sm$8(@K1=9ff^qx2w(DM)V^Wq*k|5HqJ$NGF9P7-t@eBR0lt zIcv*2h9}#6;gvW^%(3m{PWX8Z3F!FQ&1`5eZoC1GkrXoHydCt`Wf6MRQ{5tJ*WL*$ z)6GQ|e~@fI?{dDfax~9TJ&SS9#0DG8r6azpD|wHSa?QsVk1UpuziO%cvH)C= zfZ_V1&JYXz?;_JoKjlp;4+5kk7$8N)SBiB(;yl(6?gFzY`X3h|jlqvj2u|L{KtUGC zNnimLk_WIlDiR1IqF$Hv4C(qR?eIRlmsf9?IdRPXjI!U#wPF^^S5!WZJ7WmCSVtCT zHx_s+*C{cM=PwrLrmGh@7V3PkdG?t#vNaq-8;g_!zT6!7gl`)nI8Wm2SIff>NcEpm zm>7P*q*;C<5&fll5X*Pa{tDhRzPTC5s^Tkx#t;xEaKc+J}&IUgx2es%x9>A(fJf`6>MvXWHP zGCkzdA&O=@?PI`KN6J5PsKlK&&wD*UnPnDpPCWRrts;9tkz^3P=`h#9v(Dbv@6iwn zZevbb-_pco99hl#7zeB!U$%?H9D0FhELCTPv}U!N)OC==oWU|S{3y zY}*sB^GkuV|h8?%{m4hfA_|+Y)1Pm&fEEy+p#2*YZ$$Un$r$U zX!DoBEgn|OGfX=WS3x;FpI2JIAnnj{IGw>?4j!P53xOh%uo3eD&4^kwD}Yw?CbEHg;N$CQhb)8pR1G?p)z-_&r5YM}7fYzw*hDwh zZ<6;KD}a=2q}Y7rs?(&T6i#ldOk3|*tldgq!q5#ZU{&=lyvgSx@yvP@j{xgV2CazO zo$&6A#l$DJlGzT;Tt=E^str7Akrwk&t;{QL49dM#m3r%gM##!*g%Rw3MZL4ufforW zbUmT-lC(|Ms5PcnY<(Waewj<)@e@bOqHy5RCpQM1dE}8>^yA#7nwnr6iPxDqR#{oK zWW+@{6bif30G{(s(~5`MLW}j=&tL_T>9iXplrswsW1^9c5lG*h*3~OpOZcg1NY^n; z#ca3ec}Cz-zqub`xc8-><9)?GrvF91VR&8-f1}d_K#U*zjfeun2T%sc!v|5-{DUsqp|Okkx^oR+?-+?@fp< zDDmw35Ulb+8dq#eY)rhBl$yt^R+bYApcLdv&0@#lQK*iO4CoD*uU5NRr_@S|IuHAq z$~x6=ZybpE`x1n8X3M)r+Z;@{Uf+Ry_~!MC0B#nB-^_^kS&~v~@fOJF&-u|j%{HE#3{!_ou3i^u%5`RCdX%a-B<8uY62rsLv zh9XM&JDAOF9pt?6?V%?#>v!Od_XOb{(xZgYV00P`Q$rU%%O51W76|QDJ&TZ zb;o~)Ux3xqpNx?N+xohw&>|w)vKZW#FR-k(J6L+fO7-C~LhXD!QDl481bozMU_5}q zK&rhqLAhkXcfu-e+8|k;96PA98{5+2e%+LrV$w$Nm`Kb+m6kf?EyYA9f$qCXR70(7 zD3bwlQMsai&Exk{4)_<#J1OBDi0UAt;}bZE>vL(19Q3PYIC^S0;P9>Q39eYx>L)W7 zgO3mNGs_zEUuJ%h@OLOQ*t!Xeb>E^qo9bU)G~O?uSc<&RCal1`-5PAJ@BV~eayvz3 zGlsyGKqcl`wj%}Yf_M5p$s6|3R%!NDnnU)kD94-rn>$qzzqy^%3n{Zje0>Fah067J zOTJTxENTlJn~tLL|h(vAVbjuBQT2BgwvLu$z>!ts>$Fu)Q^P4` z%=1jk(UBI?3=5VZk}z=$h$+5h)f~X@Qr(g*KEd>e3e3J?zE0DWG}d>YJ0Cd?sw*{8 z<25@)oP8r?4$p^gh#_D}lt*i`Ng`xr7GJLc4Es{qonWsK&m@3-%k)stCM>NZQsdJd zW5eq1e7ZF$(fO=msch3_(4}ZaiuyiA+hTD{poZ`Rh=?;z*_Gn*e)O6zquZwDzU0Gb zg;)63=#P_ltNA_xzZzaY7}|d?<_|#B@$-tn71cuzd1wPoy9Ue1n*P@!lpVDKGzxotG`>HW) zSj#rTpN;t^d}s_n2O5$%!{-Owl@5fiWN9n^77l%Uin$jjgVYa!ZoD76L1ajtb!pYi@blnuLYuRw=DOio#Nh2SI_1P=^Dd1iG#FxyK>>#qhb= z0ThRQ;NBbpDK&%pNE?|c@GW;I)M=L|O^Q#`6gXo_HYpr*WJ-%oxt7nMBy)Q6=Z<;N zb~NCsnF5uy|F6C8famgS8_x_8krBxzS>HV(nJrRQ5ebRTHf*es*1EPubW?XZWwOo}%9(3DOyf*h_BS3h z?)VTCYrcQSM!WD7^YYyXU6V9y83TY9TfbDCD^PBMkw&-RW&PeC)y0vq;iIX|Dswsm!1 z%V}lh$ZH7WAxfOEm8tt?A>5-cdJ}(b1L{ln2W>4w+V5SmJs*tt-{cFjRZKrKm-Zg# zV;!f$Us0ndy`*kP-xGwIRNRnr|Jk(-o^DqR0}|3x9|R8%%${*z7j?NIk<)N%@?0s> zlrEjA%d4URmr=DqV=(XiVJ+mY(g^;vw)|y-5r*7gq_)~%q-Ab--qyr%oi-y7ezgvp z(W+zk&sF@d*^E%+HlzPhiDNACw`dcn%?SBh3=GU>M6R8!WuZZ$R}y@z^{2lVhWu4@ zcXc=9uYjD@Adu+-g(3&6{|RV_|9wp0`d|02X$jJL{38bjWtD%=afJj$>Hdl@?5_qH zg2W|%p9AQB+ko`nmdrr!TgQwsgmpCfM=s?*MKk`%2w&^t{rl9+P~G=mSStSrP6T$! z-;M}Sg&jPQ;=xHKOt3$&2$F$o`Yb6O!4k^D)g&->I`>x6%|n?F>U=}C;h&>(O3=XpWm(t&uLbJ`?7b+ z%Dcp!SvYtN<4hOw_r%K|cbuHn)5-s=5%a;z_nN=P?=4QTqiL@hufqP> zUAF8^u?==tZ*z4OOvi8WyZ4i2G{+`-1uok2U37irx>xdJli9QevDBbP?|eqcl`mUN zO%#3%3zo=INS`DO85ADB-6dg6#HMsuv$gC%x}%PWU7ScxXdJ#!M=yC*<9gjsqsnvd zI_~lgKe_zf`O?s^YmGT8o{(Lbzh^%7x(XHXM;F z)AjDkV3y9Qz=@YRUHd(=({%cmR0VTQN6sJD8{T1*heYM=GibSRCsz9Ur&o^8GBE`f z*mj!zV(nW&0m6}mO%I(H64wySLCEtTr1>`$Omh2;?kzQYNdZRHp{zdp4Q8K~-FCUT z`86!1-};<+z3N#lt(UZgG?h<0GJ02KDnkt?^DI-#&c>Ebm1kt+YiSQfR5(5&P(+LL-RbEc6Q{m?r2;*^ouc3hX;>B(Hl z$;iA!`}9TZxwFH@=WX+fY9q88%e{4YZsagMGUjXSS}MptMEe65tL4DpDak|awA94 zUd}ke#V{x(d?~(tptslF;N#f2-tUTH1-Eo;js%6jH$D{d+OvT7{&MpY6(;wc-&)^z zy(Uf~`K?hMwfug5#ttWjQ{G#bOcTYlwa+$=FLj9X9I(vSqI(}I>6=PLdq==|d$0FS zI$Jug+@vkJO!HDL=hTCaoGw^C$EcPuQz8CVnbEAr{JR1(A7_ldckbZ#-pH!1XFp6W zA`DNL#6^n@=Z5+3J~}bi|FwjLGLY^sq7-3_fmF6_u60fk3F2o8Y`U4IGpzA*2|2t0OA8fy#q&H zALmUg9@UFK(BzBfe584l>0{|=Mw9Jalhg#3CZ)5=yJXv<$@V*n`)8ZZvgtV5pL{9E zt3?ym#GOOD{K5Agt-tu(o3C$rE!_zd_3D$X2RF0x8}dk)I(_vwr`>ymd+%s^bicEH zZ{gu^nxXA)!p~3YQq|J)#$V0hNqo!lWw38c-OUi(=pCK5XsZE*FS4?uRJS4(buK&_ z!CJZ&(ufc_Fs$!ur4p*NrHZo(uG7k#u&fLQAMv06R$U*Bm6e{RMZfnP(6Dp=KE^i zDb7DwtnBwGx$@piL%L-td9HadK*(dODD8J#G@;|(!7>tq-Ax@GPFc;Xe53|QB1p$-nN$uFEzNN34&C|d6e7@dTHy@-Crh2 zT-xE^M~5;zyicd3*P+AxKGlpyVw3IQ5#i1e&Z3SZi}qeB+}5LOA#R;!PaJtl9aUX5 zRtY>^=Ns`NZ^J(3p>w-#2RJQ{N+`q(>24Cb!}F}?f-h4M6UP|i0qr|X%RMRT-t^0R zlbHw;)%B9biCh(rOp?5|mNQ#l%JR&muC4UG;JGz|=Z>~ODpU7H{Y7KS%W`eh97zc* zMZ6prYngcG-FHr>@qawYNTnT@&yi-Bayr0eUfz+dLMdTUgFj;QguU6F?NsGA0v~H% zuguuSKfQ4%Gy1V=VBy&v(#kQEai6cT%xgMGId;@^f3pxB^Gcb2FE2S5WtdHM)lJz# zPN#E{)+9wkvPUVGVA|AU;D5(?l-6R3TVD}vd<7(dND|)S$=o3kWxw%bPWhpa_gG$R zjo5W$h`xJMLi{d@PgYE?91?Bxq7SDjh1083ys8>2`{F+>f4%{k5tJrSeb$(c>I}11 z|LC?sY2{#Nlb>e?*{{ZR>wmyZzjSCg0q%3VoVXBD=*KlT?Qpcgulz<*DO!hPlbrCE zY@CHd0!4dZb5yCIbf?^V0hOf_bJuu~wpk~_U9l&wc+Oi$m_&L8R|fN6Vq{G+2L+A`d?a~SsjxyI%AD+owvKgt@wyVt(Y=sWb|oYVCOfL zpXH@r+r;;Le{sI4eoWR~P0Ty63y#bz%( zTdWQL;Zub-8@Fyws?Si5kf6mp8SS9dwj4CTjB*R6A^cPJ8m;XhXf;fj#%- zdb%!%IBXSr`T{TV=BIO~9Mk(?aezAAX5-y!VQQ6?_-{#5pw2HungZ1q7=bf!!JF#JiJuAL!8_8HpK?q+qli)4xqs_H zqTs752Mw(lh6GKpl+BThL7|chycUkCdrzMEayRo#YGl};%O7jMJ0@V(A71OzhSRW(F$vo}8r)%aqtXGLm&xz94!CyHe+tW|i2aQr=C-^;;= zvv`v&=}PKTe2XO8(O5<`k)MW_MB~|nyxzaeS3TwCTC??In@KwF1*-gB&g8Wwwn}0C zTZ91auCFEp)sQ@W1s0alLH}>0bhzXi(0~2+ln(Y6 zTi@=!lP?{6k%7=sz~kejG%Pl2!Qd$xdv(Wn5zViZ4*A7(7MmVjB+U6ywJUJZDPk@t z4A_i(Y^R_ zsKnBsCqOfirEbX3^Ic%_>W1<+tj8(mCrc-;sA^278*Y%veCAH^qn?#d@YS>Z6hHDE z1-wt?TzPY~uJ!OE9wCpxJ3Bt6FsY5b8aromdh#MS{z*pgw@t(!ft%r2Ua7SD8PnOc*e{{oUg)o4rYuyf*Uxxl)MxkH7ujju={7Po^KbsfIQuYk&N}ZgQmLLZMsGIY9Kahg~i~8M}L0TOa z^@le@2{}C@Zx;LzMsRqb3z}PpvXUj#hRH1TgtehY2S4R3!2F)zQ5%u3;`N zsn*#TtfyI_PN6m8E=>J~KUCM7O;lA;M4e-C{?OQg+;KbMiLMt^yR|jaP`uo!?NXmk zpOkZEXNcODzjG_1^(9+W8qIe4=H#5K^PgpT>9>Ajsyn{fhojQj{!lbUb8AmYQc|@G z7gGIj;P_F+DDFHhc41E|-?^hj&)b#ZI>UR|X&+D}n27H8Io~rJKkqhFgZTVn%x()i?;GWr1AC(f z5RA2tC9@|&4x9?esBNs4+hP*j`{rm~u_RZh)TL@UBF&cbo9=##2+%34tE$eDzTLMp z7Akf=f+1>v_RU+jjbl`;HWiXdH+*!4jP0?bCvVqhOW1kDcppvFERkqY&$SL~@_ON+ z$irR66YmqGCo_q2cz02^?3s<-%ZuO64!xlgQrW}ppe-C;wt>P_;=&2yA&El<(UdWh zyF=KGvp4QJ-O)jK-}dQw|MMgKsM#xzZ{I5xD$teUADN22?ZS2@Qv5@C_`o-jPh&~} z1*l8TdL|`HA%U))PoIWm^~ef-3(y!>P-hVwx3_Kf6UcoY_0eWvNjk`Os%+SC@kslj zi{84g@}gq9M4ydYmgd}GXxKZ5UJB{{#HW9mrWh`+VbSY0oszrpfa&xT2!vnUYN#N+ z^YgrIzTWafh=mt#I$0ZXsq}u>#C&};gLt{9L!O{Y*+w%2 zOS_g3B6M&}C5k~P^@6gnjR{)~=2Ca>ot|SGaz1tU9=mX*avZ788Rv7uuAt9+b0r(?^LADB;**y7 zWuKohbQ=$qb%poI`xjqrS}3FLrn!y4c4>~*P7UtnXbCn^whTBq_8pTjc;4k2lcQ@R z!_(%bXF`$JWbEq7e_9XRqa5AmGm=|6*S1k~DZ4}9#@7aRYY$I{-bXpR+JB;%P&X_3 z3^Dy%_Q)}*7^HlC;%VrkpF7=j^!1opA_r?e@935)n}a!%U-Mn21YbV#uNU!8UOp?Y zWb-}Wbk;G2bt*MYaAt0vd6IilV=by#r`Z&qWxV zx@~i}zkR|b`Msh}v3L*~aHwi$^nE^2jgUCcC+7LDXP+G~^0m5gR^lDg=@We(=2M(n z`yHw;r&kNh4rqU}-u2+Rmqo4F4dS)L)KD?;LCaZG*CR}kOxyS7)5ZJk>HF{5+gY4% zj#)$?P&+QPSl?<4`WnQ^ZeyOlXu9u9id~EH4Kri^`C`lHGThwxJ3VDd-_hPWCRVua zT_0t+?tAY(eZcwgY=UUObp7csTyZuwS_uLl@CG{^>;_ujG+FJh`7w5R_n=17PYm_L z$9-RKAJK1}HIisrtaKHyEPT}}AmupMnUg%HAc7ln`Z%<|>%#|U>C>hQlBRXNd1ZMFQ)Usr4twEH9oH%{Hnk8)YIW4O78Er(lQq509x788nWCbsi-6$3n0 zmCTzT({N45KI?m4AF|C+GKx~2TAS0*PsccrLMqFElFhCxF1n85uyN=MwO;N6v-v-$ zMZ=!Htrs+?+L}-MG6BEorLT*!c4oczIJZHYM@-T~;Y8*e6&{zv9Fe9`z0~UST2Vzi zWAkd0Q0MF@kmDTNY6G_>H)3tOPRv}ncdR$~2ruo`;e}Y638^2S@06f$w&@`sD)QVO zn0nO$KV51GBP91G29!Qyu(c$i&X|sCqxx_cwG2zugv-Fff@mDIZ@9;F4 z7(49T^5q~QywP3NGS$#sG^4Vu{`0k3taZ}fPkic+M^x7$m~~oVvE&8YSMFr-Gj=*L zoZJxUIqb9}T}xkm>}h-`!V_(T)l=%aMtw_&pJlgt6(c`}V&A-S^-UkvCFa+qDk>Qf zhn#Oki>Mc8$&0W|+EljqyXV%i^X}1>J;KOU_As+P*oH#iGPmh*&yHsw4{k>UJf=h# z@0z}6_)3JgB&o73FwN7~Gp=uegWleVkKrJts`ieU9S?+)GBlKMML~TZ;{D70crkpf zqmk8z+}`eyoT?miR2Ovia8$lK%z{fyy>Ud~$UTe~ep?5v;R8iSo`5GeIHJnqRZd4) zEbP`Dd0d*Z$;RyB?a58t8!ugRCL*d#q*FO}@32zi^X*MJOr|HjT_&n&|wGeeT*-pzoOgP!gs#SJ$*VQ+# zX%d60j@3V2_>`1{h~Io_v&6S~C;jry)72O5CzabfytB=nZg^XHz(Q)oY+w!+hb)7KD(dybrr#!QWik=OeeIy-qUpuZi=*tg`L1OLr zMb`1jvp;ydnfWL-u0>nxwOf%EtFb4aJJkn|=(++$DW1x=SGM-BoOg z?2f}}7p`BfcrMz`yDLTJRj`TtyMxj}OwvL0bA-)eBb#68i%@q?y-m6JmGI*UPYO!b z-CT!yfY=zkopry{&)r*&#aO7GkZNj@ShBeGtgC83CbC9_OZVE$)WPtJr5jy_V4L%o7%Vgq2Z#- z;+d!WexBL47V6e%g2v&e2;_KmS2}EU}#P>V>oo zp`coud@OE}L5wBls0$};-uZXl4!!0_gS(_|DW20>PJAU+Hrti5N%U>FF1PRJcjcL9 z_Uj#2Q83&>Bg`8Wm2n%mzL zI)^ng)@>})jFuO<#yLWXyV0r4)MByBxo4?;XrgQC{p;9AeGF7rp2&SJf6Skqt-1jn zDU8TS#RgVBWEYY3W!@|4C;P4&@=PJDOte^G@|yLn5+ zfLSZ>mgcGGTv{K;ZN%W3&)-xp&0Z#C)Loa4Oxo`0Udza=Oc1KHOgt~G`OMR@wQ{k> zC#}hLtE@K<|Id8apvR%5yO}&0c!lm6>5$ha7Pt#)5AnuqkA2EO zY}g;zxpd~%4us17cA!K3mA^Jg`7J&1a&T;aQqaew+u^pF)3%Cx1V zP}^+U*w0ksr7pLXc1oz2b6ZxO>(}PwFs;dtB@!&7^|QtszIf3}{@kLr@rSs}pvUbk zIarrK^O~r0vIQf0tA-}&3BSiZrTB}&6i3o)!%UKDzFeH1vNk!=Z{XG$xg_I{mZ>rOr_iE_E0?wW? z$@=5MkovL6moeU7tC=&?ydUJXHxoL`1WPq7CHoCp_66LHacRYGj>?k$hN1l;(XqLL zD7cu~5K?)hX!qydeKyA9Mq&7Z>Q>!%Hpbt&iuu0O(bg-_wX{%%>OaZ|#*!rd0- zQIEnM*I(|MkJ;XPA$z)iA5+Y}ww+VgM(JE5==;sZy|b2FE~p0^+%M;0E-j+#*DrWO zGvTLR%3g8fVqf};p=SXbJljP+()T#_dh{#zSyKGCd*IU6idT1+FV5zda-V!&x2G?& zWX`Nw?jzBZ(Nc1cX9ZxSR(u8@u*td*&J{Wa22AZOM3Jb3^5D(NWY z)wjz2C3Vu*iL1Zjw?pk)JKH&S#d$^YgOMm67&HJcTX_N$@XI9s>j_-FLI3VQBYj=$ zN=by3zky2aSoH7yGZHqj;M&h`Zvl1NvFP9ZXQaCASoDhjj5OXpFy4xEN1omYvrhjL z5=$1?Wp_gnNWgTer1St$6agw@&7YkW$DfW`38kjVRxE$Kw8@c*TK7e&lEO=dEK24> z{&ZQ4<4E>_!v|&=A`TRHG4HX_(0B8&{7`gmPydV=Yr?bh*W;u*G3GV;qM(qKlT+)@ zrcWHHHZkE3=WYG9lAF$7$#{@8l3{w~seqK!Qv7Zc&fSJVJrgBIY5W%T<<3ZSoPRSz zq4mX}2`;l>G3)eopwZ-O412}Yslcc_t{uRQKrX0#BhWd%pYdWUt!qEQkN-r$Arjhr z+p3NC(ZSTuJ0%XC-5q|_bK=mEC! zh^zJ_TR2CtFYM_tY-DOot-hfA>YBLVtt!qIbscAp0O8%$ew)wx(I0OtE@eoL(+K*q zFa1JjY;v@W_&^{*Ul!*+e7M0}YPn!Ow+)56?|u(<8+>D)e-KYh2+FxBeoz0-;$`nV`g_+GY5_8 zd|q5HAC$+ZZd9+g-X44WuI)qh)4|&uJx2T!A#qQ(Y~#~+ zm%mr0u<+f}j4y7<_~2A=vUahdvBSDZ*^E7Og}Wsf663y(ds_jae`*{tg0-*l1OFn4Hb_b*e$7?>uifJRhh%|LMCv;ql)YPyc29#})a9Bv^qKKtNgvVIm7RT2XY@V-fqe zxbF<^FaKe``CCUWhTOh)5+tP-5ny}`l2X4tX+;x1bmTBGcRcZl@SUXiJ$VPtZV5e} z;VtQq^l0tcOem8&d&^_+Lvs2=E!7CC zgz^_!_f#>8nTsZ*Yj1UA4mOw#@JVs;8&xlAn{C!1 z;-#=>U*h&F(+jFa1RZTDt^E{Y&h2Zoj6oyp|vfKDOt8{>#gFT(y}cE za+~T^7s7K;jDmrQO*i?*FDE@t(rahPV;j6;@QBa)<_pHRHm7_Lo4Xk=?Yfur;X$_E zbMX=eSGJ;-#qIF}dRg?R^H?u5iuYRebH`n}D1+UzyNF+F9`{*rp{RF1)0Kh5yDn0v z{F+(zCBE7^to`Woh(_bm4-T80CEuUSRAciKr}unEe-Ev(N}rLeWjM^m6YdZj)m`}f zgS*O2As4x@=FR7BHC&ENE2Bt1W9k|qhWqUGokk&YaC|KxYn^T!G=kh653Uh|fPGL_ z^|_&f?>y(5u;dqlWy=Npqp2C3vwD4&^9PhR@~B)tn90!hn*UNGp)_x@xw|gfl__~% zto3f{ch)VM&oS~3=)>FY8K3xq%B4<|m8w4N@NCKr;r9kb7k@sJ&LXwdXWTrhwwTR% zI$1)17@1VxpdOSkRUSXdRkJbdGIRaI5zU|J75BK0m~N5_jCFqeOxVOX{bPYx-4=hn zX%@MC-->2Nn!bRl%ZHc0(WEIanVmc(eCiW1)k%_PMCw$e@Mf8^=@g+KpMr@eHY(k< zU>N^S*|_aldz{?M@7J$JR6IsDr8QTum2VnXZ=>Y!h?gDL5WP_Q`t;eI#|nfV&i2gZ zBb5g>>wcH4J#rR3vgc&#;VUEV#T*_oKeA1Kj5$`)XZrv2ncm+0iPI?0CwwjBuhYLk zz>=j0)YbhxUZ%)9y`HZDeNTrYZ1Jpi&zt^HvqGd22PSy8y24hjojbg%*mm}C${z?b zsp?Lj4N9*V+QPskFo{B|^<3uU@ZVxBL-Wp6JjBdPyp%7^l?J^jWM-kNS+>QvM}_K; zR4Us?W|}fr-kNQc{pT5wZf$Q)6gSvsO?-%Y+fbZ(LUs(R-St9;s5c_nvCG>B&8!&y znLqP6U%=IJ!Gx)!A&MS#{l<=O7tggc_`BEseDN^ZwCf{&U;o@!=L2aCH}>k?J@`;B z4*fvC|3;>RY2=kBezP@AL((krF38U{8l}fI9*4bq*sKwL=)PyhKtu3N6)}YP+?+#Z zQRV}iR0Hh;W1q4wFx(jRp>_)FeW%qX6iB?3@D3;Ddp9wv$@)2gCj_&r_vH1r(LpBa z3laOA_~=5~+N>)bbrTZ|ZnM}w?Nw}QwKc5AZ6b8gG2 zp?jY`-pF`>{W2)PdUAQP(a5E2qtoFl0eK5op8ia59n16F)O@>kv|}zV==Hk{mvi=G zhhDoeh0Ucn8VJd>M741=UvE7_=YN}Zrw*&`>8&v|?=lwUymrJg_D{?;%4WV$9JVsp zVflGq((^>a8~Mk+S{=!n>_$ ze!MFvk4anGag%PdVGF+3$Z_8}!`#dhN3CinLX@NPsRzzDXzA}|k!@xEVw>NQ`Rv;5 zLPMO%9gEiJx!wH(5)4P}ge15IDaUK&%9%2I)R(_;xz1c}NU*$E(z!J`kfVnCI^z|+ z&Uf0W!i=nEa|mWEA>(HsTw?UIW*Y9RKbJZ}d=$4=WoT)%r~X1r)0C{>;8>DLbHOOf ztG5wDx8Akykxh0`w~kC-el~V4ZySZy#Mo$(TOg0twqq?{&@!cSJGD~(5W<*=&Dos3VjLnRC-}jxNLJ%#J_XL)cyq%#7%mU zc>euLegttd#b!pEhcA%<+b3PbrthbBhO)nP$dYPWlrLSn?m1OZR?<(=ev0S*T9aF+ zbqIweyN7o2=I_;TgY`wP@_(>X=2Uu1;3^$v28s9dxl4Rcu;%Aaj9$&Usl>@$ZZc!B z;|8{?*a;`Fc-d$!+~HA!%84J?GgUsKBX=?z_2TWjj(y9b%uSP>8ws@<>l{|31Bv&- zPHGvRn=}2P;BGwLc=VBdRoC6ma}^6VbbB#jYA+NIVK9_7M!K%rXn_v1poW`jxCcRM z&C5UMPf(eZebYUcQkEpL^RQ;|6$J-r%|{QY9S6I=-rE_d>2;qj`HV0_178ufW?KNC z$mP?$3sJ>VorhS`hpN);=IqqUL&J(X+S<74?n-69cEs86cCj(Zo9TK!)kL|t!g08g zQTsP4`%`oO-_vOcnAILLK)(Nj(nHwa?2=HO_P@+b_xJ0opr-8VPDu319fZ}OfA;T4 z43;Duul@6j8La)ce-)I3!vE_!GZv7{+S(-+`S+GX{dd+s*P1)28q(UhS*!K$Ff48@ zXVLF}{m=h@VHB%`Ui+%kiaNid?*q!juBgMmp1`%F71#306RA!W=6BZ#3FFumVPWNO zO2FlUT?r{zZ6|PbG`9dE02Tx+kc^_g{_*3$GnU_i{|^``p&IEQzx{783CUhsI5QHp#F0&;~+$;JTM zBPs5J3_>&)mL8K7hoPmk}y`o5J)1P6mvudLxErzC>?+-_M|k1tJ@(F1QZkpAf5xv8qyAg zr$FZnnxROWoa}ul42Gl=ki+mqC|TKtTvR1O^HqVxZp=q}@f3 zO6ZZj4+~xhoeS6t&^-;7hJ>vHkH?Xc5|X`-fC0#Yz=%i!$%{eOj)+H+Drl3#h!|4Z z$<;6rFo}SU3)Z&c$6nnIfdLW%WDFDnw1d762#7$hA|C?G?ECVKNOmuwERTui?<7 zqlc_777Lgd1ct#8VEYYhHzKqz5M;rO3AQ80cA`oiZ7qSs(+12hSTVWBh*#sGKepzFYc$-rPFs{~}dcmhd?A;$sO z{BRhN2p&K{7)9RAG-1X8JcviD)J7${FfV2EI&L)u}8fIGopX!u?LM+J0$fHJ>u9zk5W zSx+_>3=teYurY9?z)DCvEJ?A3zz8tjgdyVLxCdka`yNCB0lJUCoMGoQDCW>V(Ospf79>f-K2U`2h6A!{kJeKp95Q zpf8MGz?@-p0P-!v_B2SY4C8Ac`!XpF3puWU=mVu)fC1>a3b2A9Wk?|Ji^M@?1`_sQ z@+3%HLrNSC=?j<`3`T_UAcw8i&OQZic=u&l4=N$;SXrjFn$0;TiE^u>L83a;ZdL{3iN#_u(_Zx z44gLbKqQ2>1B4Il3-k(DJD>+Z`34Y`Ve%v%Ps&gQSucSUXAOk`EgJ>{Uk(Z*!p{J} zU0`xMP~&0ymjLSLK)(?I4UQ1V7(nHO@oexlV0@A!7DML;xYZgkxLilT5n-|l0T2R| zo&l-B&V2%&2>V6^0x)nu*Fgl5I}8SwPl-q(tQ`@c3c5BTkS*XaJPdzCGzKQu5dn*U zesdyFCSmUbOcB-&=({?KszE-mjuj;06mkD2qJ9H02m&smjOtIHVwJ17bULpM!QVeILNU?t=Ek!oM35ON8n;z~l=AJ7{0f75aUV1RV6- z0!t&nXbnjKy9@e0A{weQ0mB#2fMM<6-x9=+!^S`%;Pirm>j40vFuNWwfP!ntka2KLFWf-OHloi zR9y*1r@$rymElO%5~w{4P(9dkkoB%%lR*Mo1`L0|egHcwfSwGs0RYDY7#cbTP_3Z6 z7--^9c@0=;V7>{G0)ZM>BcWpebqmU$!Lb09Bak=*kaQq@abRac=>S;PVPgPo z2F7!MXA!n9NxBv6eHa4N?*c4UFu4i9fX5L!KO7!*z5!hbMh7?`KtuN%IC-FU5F{}D zLvL4uje7K;=pdjIIC%pf(v`)I-ALD?AzqKhSYW4ni0V1>3*CV-3|w zKm{`J)nR>cu(JpFn4vm5P{0uIbpTR>eoNqrhVd}qMuf`QNT6>)bua(}iVAdoKrn{Q z8K_4v+!Nt)4uNDbfxHh$X0W{vEWWUB2{a9u{0uywFggY5AiOUKNr3Jnpo+ux8=!jF z{vZI>4*k9auzjKHC1Bt(KM>boGBp9nb+GpVy%x&rfL;cZ;Yi{$41YwRSU_<~#KCw3 zFx|j#PecGq1Eenz$RN=75rL!woih=Mfyo<0Gy!(@02edt`;ycnC=G#|WH5g`u;|0S zC0H6%7e^9tK=_8P1Na-Ed>nX5pn5XUR$;a@0E5dQK%0TeQlx8@P#z0lFgqcD0fQ|0 z{751-ROSLOn9d3O@vt}wU^N4WE%bdrCWNdPw1erK;8cM5tpE&WR{^k<`naoQ1{B~B zP(2jDRrisxE}Cz$)<2vm2-yIH-+>h6nh#AP57xw?I4Kh=t4#SfHT#FW{yyITpZxatiGWWC7@R1G*LTECay;K#GI* z#ldWmKq>*6H?$ovh(Y)eFy!K4Gz4Jqxe(y=2RIm%PojY35UPWrfc^%R69EjM0Wv?J zzd>a)p#CDDIthSbpneSigZVH3>OtTGbPN!F0Hq<|K7#E95Vr)|GiV@aLwN+) zJ}=lb&~br!1Y0j~vB28lL1YWGFA)D=XCg6 F{}0M;F$@3z literal 0 HcmV?d00001 diff --git a/frontend/editor/src/core/tests/test-fixtures/mushroom-life.pdf b/frontend/editor/src/core/tests/test-fixtures/mushroom-life.pdf new file mode 100644 index 0000000000000000000000000000000000000000..62c8c3e0f69fcc9fa489531d0eb137e5a665dd63 GIT binary patch literal 66890 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

?Y?{hqvRJ6AF$G2Y`z+(WC%CG(a# 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

>XMU0DV>ok-K9(;qw$FhSX%q+JVi>A@8LS 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 TzhE#B{N948sYT_)kP-eD>c%O% literal 0 HcmV?d00001 diff --git a/frontend/editor/src/core/tests/test-fixtures/paragraph-sample.pdf b/frontend/editor/src/core/tests/test-fixtures/paragraph-sample.pdf new file mode 100644 index 0000000000000000000000000000000000000000..283df611c23899a90dc373d76022b9e33474721f GIT binary patch literal 1390 zcmY!laB3Nn zSiOEwYH@yPQF3ar0?1Wv`9K*E!y`4PEVU#vIZ@Z#z|z9R!ob|ZQo#hG1tDi?ZenC+ zVPtA%fhlKXU|?WjU}|Y$hAC%hZfam+W@%(#jwxquYHnm|Y-|SC2Mh%L2>+z4)MT*J zKwQ^~68GSe#FA8yTY&-~G2hgb%tWXB3Sc-I0HKM2fr2p*14Gj>FE76Y6u#h)%_{-= z9>g;P`35Kk)C>z`kPs}$+%j`YQi~My-Ex5TxTGfMr=)V}`=sWjmt-gyn;L<%6_*sH zCgyTg%;}wU(2LnnpzVES*BsVDBhNG;frDk`A0N1$;O+$fZZf%> zqW<~+{WELsF0%ae_h(IEc=_t@@2_5eefjayAl;P{roD3h`1WSur?>Z5xq0m-r7PaI zyHlz)N!4SL)?$s||Hq|1B=6Clx8}@}z*YCW_I-SNb}P5J+4l$6ihqR)dq31U+FlT~ ze||}=W8Ae4ZWZt1i66~69gICU$|ld>@`&Z!l!xKJyJBy;wbsRNbx-CsirOb**u<<>=16jatudoHjYVXw&R1?ou%? z3)RX6tak2`S7APV4ir7mpoT@1IiZMxWG`T<2`;eU4ok8}a<^Wq6L)J~pKj9vkE4N1Kfl{XW^TW<^YG&@ z@Am$75*5%0h;qElQ8VvxiQHYD-nVO2S9PAyXj7add)zkp@Pspwle6oLvr3ttzv^0X zYhxO>Pi)qveHCBV?t6YBZnACc$5o{d?TbIJ+4p5a{oBo%tae9NN>6Iuou}<7bxCxs zN%yb6aS!UsoV)&i-;r7`e|Ww4wzVhr|ET&~(qPu6^5JQX2lv^NQxCL1e6oq@#|t|> zy`O~wH`)#T>|_}4*W75G{NL|MUwqh#x&kagXn_=j!I@R53YJ{@LHYS53ZRlEh)dry zFD+le7{bPy7a&0#5tNz+4^b0SF8y#|)-nVdVg$-=o_Q&$kQ`|V$!YMwPEJVp@qhkA z$A-qni3i&o8#^0ioeYeOt}q^zFlXX?rlD}qXnG?fLxl>hA^siar!+ literal 0 HcmV?d00001 diff --git a/frontend/editor/src/core/tests/test-fixtures/pattern-fill-sample.pdf b/frontend/editor/src/core/tests/test-fixtures/pattern-fill-sample.pdf new file mode 100644 index 0000000000..92cf98a185 --- /dev/null +++ b/frontend/editor/src/core/tests/test-fixtures/pattern-fill-sample.pdf @@ -0,0 +1,42 @@ +%PDF-1.4 +% +1 0 obj +<> +endobj +2 0 obj +<> +endobj +3 0 obj +<>/Font<>>>/Contents 4 0 R>> +endobj +4 0 obj +<> +stream +q /Pattern cs /P0 scn 20 100 160 80 re f Q +BT /F1 14 Tf 20 40 Td (Plain text) Tj ET +endstream +endobj +5 0 obj +<> +endobj +6 0 obj +<>/Extend[true true]>> +endobj +7 0 obj +<> +endobj +xref +0 8 +0000000000 65535 f +0000000015 00000 n +0000000060 00000 n +0000000111 00000 n +0000000244 00000 n +0000000375 00000 n +0000000423 00000 n +0000000579 00000 n +trailer +<> +startxref +667 +%%EOF diff --git a/frontend/editor/src/core/tests/test-fixtures/rotated-text-sample.pdf b/frontend/editor/src/core/tests/test-fixtures/rotated-text-sample.pdf new file mode 100644 index 0000000000..9ec1eafde7 --- /dev/null +++ b/frontend/editor/src/core/tests/test-fixtures/rotated-text-sample.pdf @@ -0,0 +1,33 @@ +%PDF-1.7 +% +1 0 obj +<< /Type /Catalog /Pages 2 0 R >> +endobj +2 0 obj +<< /Type /Pages /Kids [3 0 R] /Count 1 >> +endobj +3 0 obj +<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] /Resources << /Font << /F1 5 0 R >> >> /Contents 4 0 R >> +endobj +4 0 obj +<< /Length 72 >> +stream +BT /F1 24 Tf 0.86603 0.50000 -0.50000 0.86603 200 400 Tm (Rotated) Tj ET +endstream +endobj +5 0 obj +<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica /Encoding /WinAnsiEncoding >> +endobj +xref +0 6 +0000000000 65535 f +0000000015 00000 n +0000000064 00000 n +0000000121 00000 n +0000000247 00000 n +0000000369 00000 n +trailer +<< /Size 6 /Root 1 0 R >> +startxref +466 +%%EOF \ No newline at end of file diff --git a/frontend/editor/src/core/tests/test-fixtures/shading-sample.pdf b/frontend/editor/src/core/tests/test-fixtures/shading-sample.pdf new file mode 100644 index 0000000000000000000000000000000000000000..94e2ffd77dafdae908f1db0c8fe12cb73ea6c8d1 GIT binary patch literal 1189 zcmY!laB3Nn zSiOEwYH@yPQF3ar0?1Wv`9K*E!y`4POxN7N(!#{Tz}&)8!31WyeuRHgR%$X>70`M5 zt`#Nj!6k_$sUU42f#8h9l+3(zuqnYA1`6gdoghiy)RfFbr~C?_XAOYR#K=Iw*uVhf zWyid{{1Q+gfW4nr0(4@rf(1-3EEGTjFhBXE=B1ZpC>WX=f$S|VDN0Su~znbFZZ8U&o3nBE55;?U|%d3A~NpKaOZ zL!5=rKFs-X^W$bwbp?gikmU&u>TE^^UI%N={fK>kd*MdgGpjePYB|v&pyn!}7}EG$ z%FtQki{Y}OJ<6NDusqr1(vi}+QR`63N8Y2yV`{77A9e0*E9#mt``N|+*HqUUT??rH zY8-7)_xScAKCyFM-l|gscV01?+<9*PIm!6%+xY(d=QICyzw-O*mTuO?@ijm0L_e5X z-uUtC2O+oV9w&dSvWw_q*uQP}V&2$bxK0B>sAHy}L&B|D!3z$3*X)idl zDpkSIkV`)(KOYz$K$##eeb2nKdJ5R07=hBhXI@Gw zBzqVd0{MuPT`?y)A>qgW`4b%*8XG4bY;SDrY!r4fFfzL07~!MPG;^XM$BGtDJ`aQW hsR;}W6aTV=VDUt8Nn%k+MG?>emgdG>s;aL3ZU7J6owWb} literal 0 HcmV?d00001 diff --git a/frontend/editor/src/core/tests/test-fixtures/signed-sample.pdf b/frontend/editor/src/core/tests/test-fixtures/signed-sample.pdf new file mode 100644 index 0000000000..51abd5095a --- /dev/null +++ b/frontend/editor/src/core/tests/test-fixtures/signed-sample.pdf @@ -0,0 +1,37 @@ +%PDF-1.6 +% +1 0 obj +<< /Type /Catalog /Pages 2 0 R /AcroForm << /Fields [5 0 R] /SigFlags 3 >> >> +endobj +2 0 obj +<< /Type /Pages /Kids [3 0 R] /Count 1 >> +endobj +3 0 obj +<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] /Resources << >> /Contents 4 0 R /Annots [5 0 R] >> +endobj +4 0 obj +<< /Length 44 >> +stream +BT /F1 12 Tf 72 720 Td (Signed sample) Tj ET +endstream +endobj +5 0 obj +<< /FT /Sig /Type /Annot /Subtype /Widget /T (Signature1) /Rect [72 700 272 740] /P 3 0 R /V 6 0 R /F 132 >> +endobj +6 0 obj +<< /Type /Sig /Filter /Adobe.PPKLite /SubFilter /adbe.pkcs7.detached /Name (Test Signer) /M (D:20260101000000Z) /ByteRange [0 0 0 0] /Contents <0000> >> +endobj +xref +0 7 +0000000000 65535 f +0000000015 00000 n +0000000108 00000 n +0000000165 00000 n +0000000285 00000 n +0000000379 00000 n +0000000503 00000 n +trailer +<< /Size 7 /Root 1 0 R >> +startxref +671 +%%EOF \ No newline at end of file diff --git a/frontend/editor/src/core/tests/test-fixtures/split-contents-sample.pdf b/frontend/editor/src/core/tests/test-fixtures/split-contents-sample.pdf new file mode 100644 index 0000000000000000000000000000000000000000..ec34df61025d15e2961193ef122e28299447793d GIT binary patch literal 1195 zcmaJ=dq`7J7&lY@I0gw42oW8nnIU=~PPdiMscTZEZnG>ioZFdh&U zz(fdLB~f%J^Ne!OSmNu19($eaM?yKSfU+bSV8=G3uL4RP^azHW9O_I^O6XV?>WnZ( zTWvLCXdtdUOjfgipeUeSP-lVJIzNdVE%+j=xAaukYI>;;z3l&7_@uZF7ZJKrq#trx zj#cD*f)Dw!FI-_mOV0ctDSVJrW-CTE$f&I3=CY7MG$LfwUGnW>b+<_9l& zhDLw?>YYe^4s7e2@#pP@YaKya(C1<*F8BCdAU1!Ykh*nvGMEo zw(IZin0VY#+16Kkvv_`>wRd;T>{wy$mo)pxt;ngCQ$M@E1*N)sws*Q8*2m^pEaz{; z6iS+=>RfZf&D@_4qp#;n#`}-GaYW63`Y|~Yc;#c~z{QEk+fqrWYxC&S=-~0^^+&CE zB&K>?d#NKWuusuHv2&_Jy{$h0rPnw5DI!N3+It^bb1i}#2N;bgVn|=81~55-NrVXt z^L5Hxq(P>oswR>GA0qF7BOp9|8##gzEj%BNBkSc>ceWc!OQ-){ZOv$KxmpL$x?Ja6 z;Yyb0Uv3zVNJ)Qu91eaCjGAo-nm{2T0bMP@_xzZ!W4#SKZA5@VrT`L2cAn}VFfWVI literal 0 HcmV?d00001 diff --git a/frontend/editor/src/core/tests/test-fixtures/stirling-marketing.pdf b/frontend/editor/src/core/tests/test-fixtures/stirling-marketing.pdf new file mode 100644 index 0000000000000000000000000000000000000000..16220bd5a569e805fbe258d4293e648a1795f1e7 GIT binary patch literal 257765 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%0A?i#CnGQjK7AQYRVe_h(NR) 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�&LFLPTr%izbxSOlEWl!N#uT6C*L#$*#1Z9*Gq+ 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 MQBgG=@W#Ra0SDSUB>(^b literal 0 HcmV?d00001 diff --git a/frontend/editor/src/core/tests/test-fixtures/subset-font-sample.pdf b/frontend/editor/src/core/tests/test-fixtures/subset-font-sample.pdf new file mode 100644 index 0000000000000000000000000000000000000000..81f00c78665bc90b52376199ecb1aeec80dd765e GIT binary patch literal 5315 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 +endobj +2 0 obj +<> +endobj +3 0 obj +<>>>/Contents 4 0 R>> +endobj +4 0 obj +<> +stream +BT /T3 20 Tf 20 70 Td (ab) Tj ET +BT /F1 14 Tf 20 25 Td (Normal text) Tj ET +endstream +endobj +5 0 obj +<>/FirstChar 97/LastChar 98/Widths[10 10]/Resources<<>>>> +endobj +6 0 obj +<> +endobj +7 0 obj +<> +stream +10 0 0 0 10 10 d1 +0 0 10 10 re f +endstream +endobj +8 0 obj +<> +stream +10 0 0 0 10 10 d1 +0 0 m 10 0 l 5 10 l f +endstream +endobj +10 0 obj +<> +endobj +xref +0 11 +0000000000 65535 f +0000000015 00000 n +0000000060 00000 n +0000000111 00000 n +0000000233 00000 n +0000000355 00000 n +0000000573 00000 n +0000000616 00000 n +0000000696 00000 n +0000000000 65535 f +0000000783 00000 n +trailer +<> +startxref +872 +%%EOF diff --git a/frontend/editor/src/core/tests/test-fixtures/user-sample.pdf b/frontend/editor/src/core/tests/test-fixtures/user-sample.pdf new file mode 100644 index 0000000000000000000000000000000000000000..d78d9e1efd4bf65b9a01c47f05c2c9f6f51637fc GIT binary patch literal 264772 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

{Nm283 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 F{}0M;F$@3z literal 0 HcmV?d00001 diff --git a/frontend/editor/src/core/tools/pdfTextEditor/PdfTextEditor.tsx b/frontend/editor/src/core/tools/pdfTextEditor/PdfTextEditor.tsx index fee07ac6ea..521feefc64 100644 --- a/frontend/editor/src/core/tools/pdfTextEditor/PdfTextEditor.tsx +++ b/frontend/editor/src/core/tools/pdfTextEditor/PdfTextEditor.tsx @@ -1,2052 +1,611 @@ -import { useCallback, useEffect, useMemo, useState, useRef } from "react"; +import { useCallback, useEffect, useRef, useState } from "react"; +import { Alert, Stack } from "@mantine/core"; import { useTranslation } from "react-i18next"; -import { isAxiosError } from "axios"; import DescriptionIcon from "@mui/icons-material/DescriptionOutlined"; - -import { useToolWorkflow } from "@app/contexts/ToolWorkflowContext"; -import { - useAllFiles, - useFileSelection, - useFileManagement, - useFileContext, -} from "@app/contexts/FileContext"; -import { - useNavigationActions, - useNavigationState, -} from "@app/contexts/NavigationContext"; -import { useViewer } from "@app/contexts/ViewerContext"; +import { downloadFile } from "@app/services/downloadService"; +import { useFileContext } from "@app/contexts/FileContext"; import { createStirlingFilesAndStubs } from "@app/services/fileStubHelpers"; -import { BaseToolProps, ToolComponent } from "@app/types/tool"; import type { FileId } from "@app/types/file"; -import { getDefaultWorkbench } from "@app/types/workbench"; -import { CONVERSION_ENDPOINTS } from "@app/constants/convertConstants"; -import apiClient from "@app/services/apiClient"; -import { downloadBlob, downloadTextAsFile } from "@app/utils/downloadUtils"; -import { getFilenameFromHeaders } from "@app/utils/fileResponseUtils"; -import { pdfWorkerManager } from "@app/services/pdfWorkerManager"; -import { Util } from "pdfjs-dist/legacy/build/pdf.mjs"; +import type { BaseToolProps } from "@app/types/tool"; +import { useEditorStore } from "@app/tools/pdfTextEditor/hooks/useEditorStore"; import { - PdfJsonDocument, - PdfJsonFont, - PdfJsonImageElement, - PdfJsonPage, - TextGroup, - PdfTextEditorViewData, - BoundingBox, - ConversionProgress, -} from "@app/tools/pdfTextEditor/pdfTextEditorTypes"; + useDocumentLoader, + ensureAllPagesRead, +} from "@app/tools/pdfTextEditor/hooks/useDocumentLoader"; +import { useAutoLoadFile } from "@app/tools/pdfTextEditor/hooks/useAutoLoadFile"; +import { useWorkbenchPin } from "@app/tools/pdfTextEditor/hooks/useWorkbenchPin"; +import { useUnsavedChangesGuard } from "@app/tools/pdfTextEditor/hooks/useUnsavedChangesGuard"; +import { useEditorTestGlobal } from "@app/tools/pdfTextEditor/hooks/useEditorTestGlobal"; +import { useSelectionActions } from "@app/tools/pdfTextEditor/hooks/useSelectionActions"; +import { useEditorKeyboardShortcuts } from "@app/tools/pdfTextEditor/hooks/useEditorKeyboardShortcuts"; +import { useEditorClipboard } from "@app/tools/pdfTextEditor/hooks/useEditorClipboard"; +import { FindBar } from "@app/tools/pdfTextEditor/components/FindBar"; +import { HelpOverlay } from "@app/tools/pdfTextEditor/components/HelpOverlay"; +import { SaveRiskModal } from "@app/tools/pdfTextEditor/components/SaveRiskModal"; +import { PasswordPromptModal } from "@app/tools/pdfTextEditor/components/PasswordPromptModal"; +import { EditorSaveBar } from "@app/tools/pdfTextEditor/components/EditorSaveBar"; +import { EditorSidebar } from "@app/tools/pdfTextEditor/components/EditorSidebar"; +import { EditorFileInputs } from "@app/tools/pdfTextEditor/components/EditorFileInputs"; +import { PageStage } from "@app/tools/pdfTextEditor/components/PageStage"; +import { InsertImageCommand } from "@app/tools/pdfTextEditor/commands/InsertImageCommand"; +import { InsertTextCommand } from "@app/tools/pdfTextEditor/commands/InsertTextCommand"; +import { DisplayTransform } from "@app/tools/pdfTextEditor/model/DisplayTransform"; +import { jpegExifOrientation } from "@app/tools/pdfTextEditor/util/jpegOrientation"; +import { MergeRunsCommand } from "@app/tools/pdfTextEditor/commands/MergeRunsCommand"; +import { UngroupParagraphCommand } from "@app/tools/pdfTextEditor/commands/UngroupParagraphCommand"; +import { exportToBlob } from "@app/tools/pdfTextEditor/util/exportPdf"; import { - deepCloneDocument, - getDirtyPages, - groupDocumentText, - restoreGlyphElements, - extractDocumentImages, - cloneImageElement, - cloneTextElement, - valueOr, -} from "@app/tools/pdfTextEditor/pdfTextEditorUtils"; -import PdfTextEditorView from "@app/components/tools/pdfTextEditor/PdfTextEditorView"; -import PdfTextEditorSidebar from "@app/components/tools/pdfTextEditor/PdfTextEditorSidebar"; -import type { PDFDocumentProxy } from "pdfjs-dist"; + detectSaveRisks, + hasSaveRisks, + type SaveRisks, +} from "@app/tools/pdfTextEditor/util/documentRisks"; +import { preloadFallbackFontBytes } from "@app/tools/pdfTextEditor/util/fallbackFont"; +import { visiblePageNumber } from "@app/tools/pdfTextEditor/util/dom"; +import type { SelectionState } from "@app/tools/pdfTextEditor/types"; -const WORKBENCH_VIEW_ID = "pdfTextEditorWorkbench"; const WORKBENCH_ID = "custom:pdfTextEditor" as const; +const WORKBENCH_VIEW_ID = "pdfTextEditorWorkbench"; +const INSERTED_IMAGE_RATIO = 0.4; -const sanitizeBaseName = (name?: string | null): string => { - if (!name || name.trim().length === 0) { - return "document"; - } - return name.replace(/\.[^.]+$/u, ""); -}; - -const getAutoLoadKey = (file: File): string => { - const withId = file as File & { fileId?: string; quickKey?: string }; - if (withId.fileId && typeof withId.fileId === "string") { - return withId.fileId; - } - if (withId.quickKey && typeof withId.quickKey === "string") { - return withId.quickKey; - } - return `${file.name}|${file.size}|${file.lastModified}`; -}; - -const normalizeLineArray = ( - value: string | undefined | null, - expected: number, -): string[] => { - const normalized = (value ?? "").replace(/\r/g, ""); - if (expected <= 0) { - return [normalized]; - } - const parts = normalized.split("\n"); - if (parts.length === expected) { - return parts; - } - if (parts.length < expected) { - return parts.concat(Array(expected - parts.length).fill("")); - } - const head = parts.slice(0, Math.max(expected - 1, 0)); - const tail = parts.slice(Math.max(expected - 1, 0)).join("\n"); - return [...head, tail]; -}; - -const cloneLineTemplate = ( - line: TextGroup, - text?: string, - originalText?: string, -): TextGroup => ({ - ...line, - text: text ?? line.text, - originalText: originalText ?? line.originalText, - childLineGroups: null, - lineElementCounts: null, - lineSpacing: null, - elements: line.elements.map(cloneTextElement), - originalElements: line.originalElements.map(cloneTextElement), -}); - -const expandGroupToLines = (group: TextGroup): TextGroup[] => { - if (group.childLineGroups && group.childLineGroups.length > 0) { - const textLines = normalizeLineArray( - group.text, - group.childLineGroups.length, - ); - const originalLines = normalizeLineArray( - group.originalText, - group.childLineGroups.length, - ); - return group.childLineGroups.map((child, index) => - cloneLineTemplate(child, textLines[index], originalLines[index]), - ); - } - return [cloneLineTemplate(group)]; -}; - -const mergeBoundingBoxes = (boxes: BoundingBox[]): BoundingBox => { - if (boxes.length === 0) { - return { left: 0, right: 0, top: 0, bottom: 0 }; - } - return boxes.reduce( - (acc, box) => ({ - left: Math.min(acc.left, box.left), - right: Math.max(acc.right, box.right), - top: Math.min(acc.top, box.top), - bottom: Math.max(acc.bottom, box.bottom), - }), - { ...boxes[0] }, - ); -}; - -const buildMergedGroupFromSelection = ( - groups: TextGroup[], -): TextGroup | null => { - if (groups.length === 0) { - return null; - } - - const lineTemplates = groups.flatMap(expandGroupToLines); - if (lineTemplates.length <= 1) { - return null; - } - - const lineTexts = lineTemplates.map((line) => line.text ?? ""); - const lineOriginalTexts = lineTemplates.map( - (line) => line.originalText ?? "", - ); - const combinedOriginals = lineTemplates.flatMap((line) => - line.originalElements.map(cloneTextElement), - ); - const combinedElements = combinedOriginals.map(cloneTextElement); - const mergedBounds = mergeBoundingBoxes( - lineTemplates.map((line) => line.bounds), - ); - - const spacingValues: number[] = []; - for (let index = 1; index < lineTemplates.length; index += 1) { - const prevBaseline = - lineTemplates[index - 1].baseline ?? - lineTemplates[index - 1].bounds.bottom; - const currentBaseline = - lineTemplates[index].baseline ?? lineTemplates[index].bounds.bottom; - const spacing = Math.abs(prevBaseline - currentBaseline); - if (spacing > 0) { - spacingValues.push(spacing); - } - } - const averageSpacing = - spacingValues.length > 0 - ? spacingValues.reduce((sum, value) => sum + value, 0) / - spacingValues.length - : null; - - const first = groups[0]; - const lineElementCounts = lineTemplates.map((line) => - Math.max(line.originalElements.length, 1), - ); - const paragraph: TextGroup = { - ...first, - text: lineTexts.join("\n"), - originalText: lineOriginalTexts.join("\n"), - elements: combinedElements, - originalElements: combinedOriginals, - bounds: mergedBounds, - lineSpacing: averageSpacing, - lineElementCounts: lineElementCounts.length > 1 ? lineElementCounts : null, - childLineGroups: lineTemplates.map((line, index) => - cloneLineTemplate(line, lineTexts[index], lineOriginalTexts[index]), - ), - }; - - return paragraph; -}; - -const splitParagraphGroup = (group: TextGroup): TextGroup[] => { - if (!group.childLineGroups || group.childLineGroups.length <= 1) { - return []; - } - - const templateLines = group.childLineGroups.map((child) => - cloneLineTemplate(child), - ); - const lineCount = templateLines.length; - const textLines = normalizeLineArray(group.text, lineCount); - const originalLines = normalizeLineArray(group.originalText, lineCount); - const baseCounts = - group.lineElementCounts && group.lineElementCounts.length === lineCount - ? [...group.lineElementCounts] - : templateLines.map((line) => Math.max(line.originalElements.length, 1)); - - const totalOriginals = group.originalElements.length; - const counted = baseCounts.reduce((sum, count) => sum + count, 0); - if (counted < totalOriginals && baseCounts.length > 0) { - baseCounts[baseCounts.length - 1] += totalOriginals - counted; - } - - let offset = 0; - return templateLines.map((template, index) => { - const take = Math.max(1, baseCounts[index] ?? 1); - const slice = group.originalElements - .slice(offset, offset + take) - .map(cloneTextElement); - offset += take; - return { - ...template, - id: `${group.id}-line-${index + 1}-${Date.now()}-${index}`, - text: textLines[index] ?? "", - originalText: originalLines[index] ?? "", - elements: slice.map(cloneTextElement), - originalElements: slice, - lineElementCounts: null, - lineSpacing: null, - childLineGroups: null, - }; - }); -}; - -const PdfTextEditor = ({ onComplete, onError }: BaseToolProps) => { +export default function PdfTextEditor(_props: BaseToolProps) { const { t } = useTranslation(); - const { - registerCustomWorkbenchView, - unregisterCustomWorkbenchView, - setCustomWorkbenchViewData, - clearCustomWorkbenchViewData, - setLeftPanelView, - } = useToolWorkflow(); - const { actions: navigationActions } = useNavigationActions(); - const navigationState = useNavigationState(); - const { addFiles } = useFileManagement(); - const { consumeFiles, selectors } = useFileContext(); + const { store, state } = useEditorStore(); + const load = useDocumentLoader(store); - const [loadedDocument, setLoadedDocument] = useState( - null, + const [selection, setSelection] = useState( + store.selection.value, ); - const [groupsByPage, setGroupsByPage] = useState([]); - const [imagesByPage, setImagesByPage] = useState([]); - const [selectedPage, setSelectedPage] = useState(0); - const [fileName, setFileName] = useState(""); - const [errorMessage, setErrorMessage] = useState(null); - const [isGeneratingPdf, setIsGeneratingPdf] = useState(false); - const [isSavingToWorkbench, setIsSavingToWorkbench] = useState(false); - const [shouldNavigateAfterSave, setShouldNavigateAfterSave] = useState(false); - const [isConverting, setIsConverting] = useState(false); - const [conversionProgress, setConversionProgress] = - useState(null); - const [forceSingleTextElement, setForceSingleTextElement] = useState(true); - const [groupingMode, setGroupingMode] = useState< - "auto" | "paragraph" | "singleLine" - >("auto"); - const [hasVectorPreview, setHasVectorPreview] = useState(false); - const [pagePreviews, setPagePreviews] = useState>( - new Map(), - ); - const [autoScaleText, setAutoScaleText] = useState(true); - - // Lazy loading state - const [isLazyMode, setIsLazyMode] = useState(false); - const [cachedJobId, setCachedJobId] = useState(null); - const [loadedImagePages, setLoadedImagePages] = useState>( - new Set(), - ); - const [loadingImagePages, setLoadingImagePages] = useState>( - new Set(), - ); - - const originalImagesRef = useRef([]); - const originalGroupsRef = useRef([]); - const imagesByPageRef = useRef([]); - const lastLoadedFileRef = useRef(null); - const autoLoadKeyRef = useRef(null); + const [findOpen, setFindOpen] = useState(false); + const [helpOpen, setHelpOpen] = useState(false); + const [openedFileName, setOpenedFileName] = useState(null); + // Set only when the document came from the workbench; a drag-dropped + // file has no fileId and can only be downloaded. Mirrored into state so the + // sidebar's file switcher can mark which workbench file is open. const sourceFileIdRef = useRef(null); - const loadRequestIdRef = useRef(0); - const latestPdfRequestIdRef = useRef(null); - const loadedDocumentRef = useRef(null); - const loadedImagePagesRef = useRef>(new Set()); - const loadingImagePagesRef = useRef>(new Set()); - const pdfDocumentRef = useRef(null); - const previewRequestIdRef = useRef(0); - const previewRenderingRef = useRef>(new Set()); - const pagePreviewsRef = useRef>(pagePreviews); - const previewScaleRef = useRef>(new Map()); - const cachedJobIdRef = useRef(null); - const previousCachedJobIdRef = useRef(null); - const cacheRecoveryInProgressRef = useRef(false); - const cacheRecoveryAttemptsRef = useRef(0); - const recoverCacheAndReloadRef = useRef<() => Promise>( - async () => false, - ); - - // Keep ref in sync with state for access in async callbacks - useEffect(() => { - loadedDocumentRef.current = loadedDocument; - }, [loadedDocument]); - - useEffect(() => { - loadedImagePagesRef.current = new Set(loadedImagePages); - }, [loadedImagePages]); - - useEffect(() => { - loadingImagePagesRef.current = new Set(loadingImagePages); - }, [loadingImagePages]); - - useEffect(() => { - pagePreviewsRef.current = pagePreviews; - }, [pagePreviews]); - - useEffect(() => { - return () => { - if (pdfDocumentRef.current) { - pdfWorkerManager.destroyDocument(pdfDocumentRef.current); - pdfDocumentRef.current = null; - } - }; + const [sourceFileId, setSourceFileId] = useState(null); + const setSourceFile = useCallback((id: FileId | null) => { + sourceFileIdRef.current = id; + setSourceFileId(id); }, []); + const { addFiles, consumeFiles, selectors } = useFileContext(); + // Saving replaces the workbench file, so for a moment the selection points at + // a file the editor has not adopted yet. Auto-load must sit that out. + const [applying, setApplying] = useState(false); - const isCacheUnavailableError = useCallback((error: unknown): boolean => { - const status = isAxiosError(error) ? error.response?.status : undefined; - // Treat any 410 as cache unavailable, since responseType: 'blob' makes - // it impossible to reliably check the JSON body - return status === 410; - }, []); - - const dirtyPages = useMemo( - () => - getDirtyPages( - groupsByPage, - imagesByPage, - originalGroupsRef.current, - originalImagesRef.current, - ), - [groupsByPage, imagesByPage], - ); - const hasChanges = useMemo(() => dirtyPages.some(Boolean), [dirtyPages]); - const hasDocument = loadedDocument !== null; - - // Sync hasChanges to navigation context so navigation guards can block - useEffect(() => { - navigationActions.setHasUnsavedChanges(hasChanges); - return () => { - navigationActions.setHasUnsavedChanges(false); - }; - }, [hasChanges, navigationActions]); - - // Navigate to files view AFTER the unsaved changes state is properly cleared - useEffect(() => { - if (shouldNavigateAfterSave && !navigationState.hasUnsavedChanges) { - setShouldNavigateAfterSave(false); - navigationActions.setToolAndWorkbench(null, getDefaultWorkbench()); - } - }, [ - shouldNavigateAfterSave, - navigationState.hasUnsavedChanges, - navigationActions, - ]); - - const viewLabel = useMemo( - () => t("pdfTextEditor.viewLabel", "PDF Editor"), - [t], - ); - const { selectedFiles } = useFileSelection(); - const { files: allFiles } = useAllFiles(); - const { activeFileId } = useViewer(); - - // The file the tool should auto-load: prefer the sidebar selection, then - // whatever the viewer is currently showing (so opening PDF Editor from the - // viewer picks up that file), then the single workbench file if there is - // only one. Returns null if the choice is ambiguous (no selection, no - // viewer file, and multiple files in the workbench). - const autoLoadFile = useMemo(() => { - if (selectedFiles[0]) return selectedFiles[0]; - if (activeFileId) { - const viewerFile = allFiles.find( - (f) => (f.fileId as string) === activeFileId, - ); - if (viewerFile) return viewerFile; - } - if (allFiles.length === 1) return allFiles[0]; - return null; - }, [selectedFiles, activeFileId, allFiles]); - - const resetToDocument = useCallback( - ( - document: PdfJsonDocument | null, - mode: "auto" | "paragraph" | "singleLine", - ) => { - if (!document) { - setGroupsByPage([]); - setImagesByPage([]); - originalImagesRef.current = []; - imagesByPageRef.current = []; - setLoadedImagePages(new Set()); - setLoadingImagePages(new Set()); - loadedImagePagesRef.current = new Set(); - loadingImagePagesRef.current = new Set(); - setSelectedPage(0); - setIsLazyMode(false); - setCachedJobId(null); - cachedJobIdRef.current = null; - return; - } - const cloned = deepCloneDocument(document); - const groups = groupDocumentText(cloned, mode); - const images = extractDocumentImages(cloned); - const originalImages = images.map((page) => page.map(cloneImageElement)); - originalImagesRef.current = originalImages; - originalGroupsRef.current = groups.map((page) => - page.map((group) => ({ ...group })), - ); - imagesByPageRef.current = images.map((page) => - page.map(cloneImageElement), - ); - const initialLoaded = new Set(); - originalImages.forEach((pageImages, index) => { - if (pageImages.length > 0) { - initialLoaded.add(index); - } - }); - setGroupsByPage(groups); - setImagesByPage(images); - setLoadedImagePages(initialLoaded); - setLoadingImagePages(new Set()); - loadedImagePagesRef.current = new Set(initialLoaded); - loadingImagePagesRef.current = new Set(); - setSelectedPage(0); + useEditorTestGlobal(store); + useUnsavedChangesGuard(state.dirty); + const pinWorkbench = useWorkbenchPin({ + workbenchId: WORKBENCH_ID, + workbenchViewId: WORKBENCH_VIEW_ID, + label: t("pdfTextEditor.workbenchLabel", "Editor"), + icon: , + component: PageStage, + }); + // Uploading flips the workbench to Active Files, so landing a document has to + // pin the canvas back. useAutoLoadFile only fires for a genuine file change. + const handleFileChosen = useCallback( + (name: string, fileId?: FileId) => { + setOpenedFileName(name); + setSourceFile(fileId ?? null); + pinWorkbench(); }, - [], + [pinWorkbench, setSourceFile], + ); + const { openFile: openWorkbenchFile, adopt: adoptFile } = useAutoLoadFile( + load, + handleFileChosen, + sourceFileId, + applying, + state, ); - const clearPdfPreview = useCallback(() => { - previewRequestIdRef.current += 1; - previewRenderingRef.current.clear(); - previewScaleRef.current.clear(); - const empty = new Map(); - pagePreviewsRef.current = empty; - setPagePreviews(empty); - if (pdfDocumentRef.current) { - pdfWorkerManager.destroyDocument(pdfDocumentRef.current); - pdfDocumentRef.current = null; - } - setHasVectorPreview(false); - }, []); - - const clearCachedJob = useCallback((jobId: string | null) => { - if (!jobId) { - return; - } - console.log( - `[PdfTextEditor] Cleaning up cached document for jobId: ${jobId}`, - ); - apiClient - .post(`/api/v1/convert/pdf/text-editor/clear-cache/${jobId}`) - .catch((error) => { - console.warn("[PdfTextEditor] Failed to clear cache:", error); - }); - }, []); + useEffect(() => store.selection.subscribe(setSelection), [store]); + // Warm the Unicode fallback font so a non-Latin edit can embed it instead of + // dropping the glyphs. useEffect(() => { - // Clear old cached job when job ID changes - const previousJobId = previousCachedJobIdRef.current; - if (previousJobId && previousJobId !== cachedJobId) { - console.log( - `[PdfTextEditor] Clearing old cache for jobId: ${previousJobId}, new jobId: ${cachedJobId}`, - ); - clearCachedJob(previousJobId); - } - // Update the previous jobId ref for next time - previousCachedJobIdRef.current = cachedJobId; - }, [cachedJobId, clearCachedJob]); + void preloadFallbackFontBytes(); + }, []); - const initializePdfPreview = useCallback( - async (file: File) => { - const requestId = ++previewRequestIdRef.current; + const sel = useSelectionActions(store); + + // Guards against re-entrant saves while a (synchronous) serialize runs. + const savingRef = useRef(false); + // Pending save-risk warning (signatures/XFA) shown before the actual save. + const [saveRisks, setSaveRisks] = useState(null); + // docPtr the user already acknowledged risks for, so we don't re-nag. + const ackedRiskRef = useRef<{ doc: object; sig: string } | null>(null); + + // Land the edit in the workbench the way every other tool does: replace the + // file it came from, or add it if the document was opened from disk. Without + // this the editor is an island and the next tool runs on the pre-edit bytes. + const applyToWorkbench = useCallback( + async (blob: Blob, filename: string) => { + const edited = new File([blob], filename, { type: "application/pdf" }); + const sourceId = sourceFileIdRef.current; + const parentStub = sourceId + ? selectors.getStirlingFileStub(sourceId) + : null; + setApplying(true); try { - const buffer = await file.arrayBuffer(); - const pdfDocument = await pdfWorkerManager.createDocument(buffer); - if (previewRequestIdRef.current !== requestId) { - pdfWorkerManager.destroyDocument(pdfDocument); + if (sourceId && parentStub) { + const { stirlingFiles, stubs } = await createStirlingFilesAndStubs( + [edited], + parentStub, + "pdfTextEditor", + ); + await consumeFiles([sourceId], stirlingFiles, stubs); + // Claim the replacement before releasing the hold, otherwise the + // editor sees an unfamiliar selection and re-opens the file it just + // wrote, throwing away undo history. + if (stirlingFiles[0]) adoptFile(stirlingFiles[0]); + setSourceFile(stubs[0]?.id ?? null); return; } - if (pdfDocumentRef.current) { - pdfWorkerManager.destroyDocument(pdfDocumentRef.current); - } - pdfDocumentRef.current = pdfDocument; - previewRenderingRef.current.clear(); - previewScaleRef.current.clear(); - const empty = new Map(); - pagePreviewsRef.current = empty; - setPagePreviews(empty); - setHasVectorPreview(true); - } catch (error) { - if (previewRequestIdRef.current === requestId) { - console.warn( - "[PdfTextEditor] Failed to initialise PDF preview:", - error, - ); - clearPdfPreview(); - } - } - }, - [clearPdfPreview], - ); - - // Load images for a page in lazy mode - const loadImagesForPage = useCallback( - async (pageIndex: number) => { - if (!isLazyMode) { - return; - } - if (!cachedJobId) { - console.log("[loadImagesForPage] No cached jobId, skipping"); - return; - } - if ( - loadedImagePagesRef.current.has(pageIndex) || - loadingImagePagesRef.current.has(pageIndex) - ) { - return; - } - - loadingImagePagesRef.current.add(pageIndex); - setLoadingImagePages((prev) => { - const next = new Set(prev); - next.add(pageIndex); - return next; - }); - - const pageNumber = pageIndex + 1; - const start = performance.now(); - - try { - const [pageResponse, pageFontsResponse] = await Promise.all([ - apiClient.get( - `/api/v1/convert/pdf/text-editor/page/${cachedJobId}/${pageNumber}`, - { - responseType: "json", - }, - ), - apiClient.get( - `/api/v1/convert/pdf/text-editor/fonts/${cachedJobId}/${pageNumber}`, - { - responseType: "json", - }, - ), - ]); - - const pageData = pageResponse.data as PdfJsonPage; - const pageFonts = Array.isArray(pageFontsResponse.data) - ? (pageFontsResponse.data as PdfJsonFont[]) - : []; - const normalizedImages = (pageData.imageElements ?? []).map( - cloneImageElement, - ); - - if (imagesByPageRef.current.length <= pageIndex) { - imagesByPageRef.current.length = pageIndex + 1; - } - imagesByPageRef.current[pageIndex] = - normalizedImages.map(cloneImageElement); - - setLoadedDocument((prevDoc) => { - if (!prevDoc || !prevDoc.pages) { - return prevDoc; - } - const nextPages = [...prevDoc.pages]; - const existingPage = nextPages[pageIndex] ?? {}; - const fontMap = new Map(); - for (const existingFont of prevDoc.fonts ?? []) { - if (!existingFont) { - continue; - } - const existingKey = - existingFont.uid || - `${existingFont.pageNumber ?? -1}:${existingFont.id ?? ""}`; - fontMap.set(existingKey, existingFont); - } - if (pageFonts.length > 0) { - for (const font of pageFonts) { - if (!font) { - continue; - } - const key = - font.uid || `${font.pageNumber ?? -1}:${font.id ?? ""}`; - fontMap.set(key, font); - } - } - const nextFonts = Array.from(fontMap.values()); - nextPages[pageIndex] = { - ...existingPage, - imageElements: normalizedImages.map(cloneImageElement), - }; - return { - ...prevDoc, - fonts: nextFonts, - pages: nextPages, - }; + const added = await addFiles([edited], { + selectFiles: true, + derivedFromTool: true, }); - - setImagesByPage((prev) => { - const next = [...prev]; - while (next.length <= pageIndex) { - next.push([]); - } - next[pageIndex] = normalizedImages.map(cloneImageElement); - return next; - }); - - if (originalImagesRef.current.length <= pageIndex) { - originalImagesRef.current.length = pageIndex + 1; - } - originalImagesRef.current[pageIndex] = - normalizedImages.map(cloneImageElement); - - setLoadedImagePages((prev) => { - const next = new Set(prev); - next.add(pageIndex); - return next; - }); - loadedImagePagesRef.current.add(pageIndex); - - console.log( - `[loadImagesForPage] Loaded ${normalizedImages.length} images for page ${pageNumber} in ${( - performance.now() - start - ).toFixed(2)}ms`, - ); - } catch (error) { - console.error( - `[loadImagesForPage] Failed to load images for page ${pageNumber}:`, - error, - ); - if (isCacheUnavailableError(error)) { - console.log( - "[loadImagesForPage] Cache expired, triggering automatic recovery...", - ); - // Automatically recover by reloading the file - void recoverCacheAndReloadRef.current(); - } + if (added[0]) adoptFile(added[0]); + setSourceFile(added[0]?.fileId ?? null); } finally { - loadingImagePagesRef.current.delete(pageIndex); - setLoadingImagePages((prev) => { - const next = new Set(prev); - next.delete(pageIndex); - return next; - }); + setApplying(false); } }, - [isLazyMode, cachedJobId, isCacheUnavailableError], + [addFiles, adoptFile, consumeFiles, selectors, setSourceFile], ); - const handleLoadFile = useCallback( - async (file: File | null) => { - if (!file) { - return; - } - - lastLoadedFileRef.current = file; - const requestId = loadRequestIdRef.current + 1; - loadRequestIdRef.current = requestId; - - const _fileKey = getAutoLoadKey(file); - const isPdf = - file.type === "application/pdf" || - file.name.toLowerCase().endsWith(".pdf"); - + const doSave = useCallback( + async (download: boolean) => { + if (!store.document || savingRef.current) return; + savingRef.current = true; + store.setError(null); try { - let parsed: PdfJsonDocument | null = null; - let shouldUseLazyMode = false; - let pendingJobId: string | null = null; - - if (isPdf) { - latestPdfRequestIdRef.current = requestId; - setIsConverting(true); - setConversionProgress({ - percent: 0, - stage: "uploading", - message: "Uploading PDF file to server...", - }); - - const formData = new FormData(); - formData.append("fileInput", file); - - console.log("Sending conversion request with async=true"); - const response = await apiClient.post( - `${CONVERSION_ENDPOINTS["pdf-text-editor"]}?async=true&lightweight=true`, - formData, - { - responseType: "json", - }, - ); - - console.log("Conversion response:", response.data); - const jobId = response.data.jobId; - - if (!jobId) { - console.error("No job ID in response:", response.data); - throw new Error("No job ID received from server"); - } - - pendingJobId = jobId; - console.log("Got job ID:", jobId); - setConversionProgress({ - percent: 3, - stage: "processing", - message: "Starting conversion...", - }); - - let jobComplete = false; - let attempts = 0; - const maxAttempts = 600; - let pollDelay = 500; - - while (!jobComplete && attempts < maxAttempts) { - await new Promise((resolve) => setTimeout(resolve, pollDelay)); - attempts += 1; - if (pollDelay < 10000) { - pollDelay = Math.min(10000, Math.floor(pollDelay * 1.5)); - } - - try { - const statusResponse = await apiClient.get( - `/api/v1/general/job/${jobId}`, - ); - const jobStatus = statusResponse.data; - console.log(`Job status (attempt ${attempts}):`, jobStatus); - - const percent = Math.min( - Math.max(jobStatus.progress ?? 0, 0), - 100, - ); - const stage = jobStatus.stage || "processing"; - const message = jobStatus.note || "Converting PDF to JSON..."; - const current = jobStatus.current ?? undefined; - const total = jobStatus.total ?? undefined; - setConversionProgress({ - percent, - stage, - message, - current, - total, - }); - - if (jobStatus.complete) { - if (jobStatus.error) { - console.error("Job failed:", jobStatus.error); - throw new Error(jobStatus.error); - } - - console.log("Job completed, retrieving JSON result..."); - jobComplete = true; - - const resultResponse = await apiClient.get( - `/api/v1/general/job/${jobId}/result`, - { - responseType: "blob", - }, - ); - - const jsonText = await resultResponse.data.text(); - const result = JSON.parse(jsonText); - - if (!Array.isArray(result.pages)) { - console.error( - "Conversion result missing page array:", - result, - ); - throw new Error( - "PDF conversion result did not include page data. Please update the server.", - ); - } - - const docResult = result as PdfJsonDocument; - parsed = { - ...docResult, - pages: docResult.pages ?? [], - }; - shouldUseLazyMode = Boolean(docResult.lazyImages); - pendingJobId = shouldUseLazyMode ? jobId : null; - setConversionProgress(null); - } else { - console.log("Job not complete yet, continuing to poll..."); - } - } catch (pollError) { - console.error("Error polling job status:", pollError); - const status = isAxiosError(pollError) - ? pollError.response?.status - : undefined; - console.error("Poll error details:", { - status, - data: isAxiosError(pollError) - ? pollError.response?.data - : undefined, - message: - pollError instanceof Error ? pollError.message : undefined, - }); - if (status === 404) { - throw new Error("Job not found on server", { - cause: pollError, - }); - } - } - } - - if (!jobComplete) { - throw new Error("Conversion timed out"); - } - if (!parsed) { - throw new Error("Conversion did not return JSON content"); - } - } else { - const content = await file.text(); - const docResult = JSON.parse(content) as PdfJsonDocument; - parsed = { - ...docResult, - pages: docResult.pages ?? [], - }; - shouldUseLazyMode = false; - pendingJobId = null; - } - - setConversionProgress(null); - - if (loadRequestIdRef.current !== requestId) { - return; - } - - if (!parsed) { - throw new Error("Failed to parse PDF JSON document"); - } - - console.log( - `[PdfTextEditor] Document loaded. Lazy image mode: ${shouldUseLazyMode}, Pages: ${parsed.pages?.length || 0}`, + // Yield once so React can paint the disabled/saving state before the + // synchronous PDFium serialize blocks the main thread. + await new Promise((resolve) => setTimeout(resolve, 0)); + // The position that is about to be written out. Anything the user edits + // while the export runs is NOT in these bytes, so it must stay dirty. + const exported = store.savedPosition(); + const { blob, filename } = await exportToBlob( + store.document, + openedFileName, ); - - if (isPdf) { - initializePdfPreview(file); - } else { - clearPdfPreview(); - } - - setLoadedDocument(parsed); - resetToDocument(parsed, groupingMode); - setIsLazyMode(shouldUseLazyMode); - const newJobId = shouldUseLazyMode ? pendingJobId : null; - setCachedJobId(newJobId); - cachedJobIdRef.current = newJobId; - setFileName(file.name); - setErrorMessage(null); - } catch (error) { - console.error("Failed to load file", error); - console.error("Error details:", { - message: error instanceof Error ? error.message : undefined, - response: isAxiosError(error) ? error.response?.data : undefined, - stack: error instanceof Error ? error.stack : undefined, - }); - - if (loadRequestIdRef.current !== requestId) { - return; - } - - setLoadedDocument(null); - resetToDocument(null, groupingMode); - clearPdfPreview(); - setIsLazyMode(false); - setCachedJobId(null); - cachedJobIdRef.current = null; - - if (isPdf) { - const errorMsg = - (error instanceof Error ? error.message : undefined) || - t( - "pdfTextEditor.conversionFailed", - "Failed to convert PDF. Please try again.", - ); - setErrorMessage(errorMsg); - console.error("Setting error message:", errorMsg); - } else { - setErrorMessage( - t( - "pdfTextEditor.errors.invalidJson", - "Unable to read the JSON file. Ensure it was generated by the PDF to JSON tool.", - ), - ); - } + // Apply first and unconditionally. Gating the write-back on the browser + // download dialog meant cancelling it silently discarded the save. + await applyToWorkbench(blob, filename); + store.markSaved(exported); + if (download) await downloadFile({ data: blob, filename }); + } catch (err) { + // Surface the failure instead of silently dropping it - the user + // must not believe a broken save succeeded. + store.setError(err instanceof Error ? err.message : String(err)); } finally { - if (isPdf && latestPdfRequestIdRef.current === requestId) { - setIsConverting(false); - } + savingRef.current = false; } }, - [groupingMode, resetToDocument, t], + [store, openedFileName, applyToWorkbench], ); - const recoverCacheAndReload = useCallback(async () => { - if (cacheRecoveryInProgressRef.current) { - return false; - } - if (cacheRecoveryAttemptsRef.current >= 2) { - console.warn("[PdfTextEditor] Cache recovery limit reached"); - return false; - } - cacheRecoveryAttemptsRef.current += 1; - const file = lastLoadedFileRef.current; - if (!file) { - console.warn("[PdfTextEditor] No file available for cache recovery"); - return false; - } - cacheRecoveryInProgressRef.current = true; - try { - console.log( - "[PdfTextEditor] Automatically reloading file due to cache expiration...", - ); - await handleLoadFile(file); - console.log("[PdfTextEditor] Cache recovery successful"); - return true; - } catch (error) { - console.error("[PdfTextEditor] Cache recovery failed", error); - return false; - } finally { - cacheRecoveryInProgressRef.current = false; - } - }, [handleLoadFile]); + // Which action the risk modal is currently gating. + const pendingDownloadRef = useRef(false); - useEffect(() => { - recoverCacheAndReloadRef.current = recoverCacheAndReload; - }, [recoverCacheAndReload]); - - // Wrapper for loading files from the dropzone - adds to workbench first - const handleLoadFileFromDropzone = useCallback( - async (file: File) => { - // Add the file to the workbench so it appears in the file list - const addedFiles = await addFiles([file]); - // Capture the file ID for save-to-workbench functionality - if (addedFiles.length > 0 && addedFiles[0].fileId) { - sourceFileIdRef.current = addedFiles[0].fileId; - } - // Then load it into the editor - void handleLoadFile(file); - }, - [addFiles, handleLoadFile], - ); - - const handleSelectPage = useCallback( - (pageIndex: number) => { - setSelectedPage(pageIndex); - // Trigger lazy loading for images on the selected page - if (isLazyMode) { - void loadImagesForPage(pageIndex); - } - }, - [isLazyMode, loadImagesForPage], - ); - - const handleGroupTextChange = useCallback( - (pageIndex: number, groupId: string, value: string) => { - setGroupsByPage((previous) => - previous.map((groups, idx) => - idx !== pageIndex - ? groups - : groups.map((group) => - group.id === groupId ? { ...group, text: value } : group, - ), - ), - ); - }, - [], - ); - - const handleGroupDelete = useCallback( - (pageIndex: number, groupId: string) => { - console.log(`🗑️ Deleting group ${groupId} from page ${pageIndex}`); - setGroupsByPage((previous) => { - const updated = previous.map((groups, idx) => { - if (idx !== pageIndex) return groups; - const filtered = groups.filter((group) => group.id !== groupId); - console.log( - ` Before: ${groups.length} groups, After: ${filtered.length} groups`, - ); - return filtered; - }); - return updated; - }); - }, - [], - ); - - const handleMergeGroups = useCallback( - (pageIndex: number, groupIds: string[]): boolean => { - if (groupIds.length < 2) { - return false; - } - let updated = false; - setGroupsByPage((previous) => - previous.map((groups, idx) => { - if (idx !== pageIndex) { - return groups; - } - const indices = groupIds - .map((id) => groups.findIndex((group) => group.id === id)) - .filter((index) => index >= 0); - if (indices.length !== groupIds.length) { - return groups; - } - const sorted = [...indices].sort((a, b) => a - b); - for (let i = 1; i < sorted.length; i += 1) { - if (sorted[i] !== sorted[i - 1] + 1) { - return groups; - } - } - const selection = sorted.map((position) => groups[position]); - const merged = buildMergedGroupFromSelection(selection); - if (!merged) { - return groups; - } - const next = [ - ...groups.slice(0, sorted[0]), - merged, - ...groups.slice(sorted[sorted.length - 1] + 1), - ]; - updated = true; - return next; - }), - ); - return updated; - }, - [], - ); - - const handleUngroupGroup = useCallback( - (pageIndex: number, groupId: string): boolean => { - let updated = false; - setGroupsByPage((previous) => - previous.map((groups, idx) => { - if (idx !== pageIndex) { - return groups; - } - const targetIndex = groups.findIndex((group) => group.id === groupId); - if (targetIndex < 0) { - return groups; - } - const targetGroup = groups[targetIndex]; - const splits = splitParagraphGroup(targetGroup); - if (splits.length <= 1) { - return groups; - } - const next = [ - ...groups.slice(0, targetIndex), - ...splits, - ...groups.slice(targetIndex + 1), - ]; - updated = true; - return next; - }), - ); - return updated; - }, - [], - ); - - const handleImageTransform = useCallback( - ( - pageIndex: number, - imageId: string, - next: { - left: number; - bottom: number; - width: number; - height: number; - transform: number[]; - }, - ) => { - setImagesByPage((previous) => { - const current = previous[pageIndex] ?? []; - let changed = false; - const updatedPage = current.map((image) => { - if ((image.id ?? "") !== imageId) { - return image; - } - const originalTransform = - image.transform ?? - originalImagesRef.current[pageIndex]?.find( - (base) => (base.id ?? "") === imageId, - )?.transform; - const scaleXSign = - originalTransform && originalTransform.length >= 6 - ? Math.sign(originalTransform[0]) || 1 - : 1; - const scaleYSign = - originalTransform && originalTransform.length >= 6 - ? Math.sign(originalTransform[3]) || 1 - : 1; - const right = next.left + next.width; - const top = next.bottom + next.height; - const updatedImage: PdfJsonImageElement = { - ...image, - x: next.left, - y: next.bottom, - left: next.left, - bottom: next.bottom, - right, - top, - width: next.width, - height: next.height, - transform: - scaleXSign < 0 || scaleYSign < 0 - ? [ - next.width * scaleXSign, - 0, - 0, - next.height * scaleYSign, - next.left, - scaleYSign >= 0 ? next.bottom : next.bottom + next.height, - ] - : null, - }; - - const isSame = - Math.abs(valueOr(image.left, 0) - next.left) < 1e-4 && - Math.abs(valueOr(image.bottom, 0) - next.bottom) < 1e-4 && - Math.abs(valueOr(image.width, 0) - next.width) < 1e-4 && - Math.abs(valueOr(image.height, 0) - next.height) < 1e-4; - - if (!isSame) { - changed = true; - } - return updatedImage; - }); - - if (!changed) { - return previous; - } - - const nextImages = previous.map((images, idx) => - idx === pageIndex ? updatedPage : images, - ); - if (imagesByPageRef.current.length <= pageIndex) { - imagesByPageRef.current.length = pageIndex + 1; - } - imagesByPageRef.current[pageIndex] = updatedPage.map(cloneImageElement); - return nextImages; - }); - }, - [], - ); - - const handleImageReset = useCallback((pageIndex: number, imageId: string) => { - const baseline = originalImagesRef.current[pageIndex]?.find( - (image) => (image.id ?? "") === imageId, - ); - if (!baseline) { - return; - } - setImagesByPage((previous) => { - const current = previous[pageIndex] ?? []; - let changed = false; - const updatedPage = current.map((image) => { - if ((image.id ?? "") !== imageId) { - return image; - } - changed = true; - return cloneImageElement(baseline); - }); - - if (!changed) { - return previous; - } - - const nextImages = previous.map((images, idx) => - idx === pageIndex ? updatedPage : images, - ); - if (imagesByPageRef.current.length <= pageIndex) { - imagesByPageRef.current.length = pageIndex + 1; - } - imagesByPageRef.current[pageIndex] = updatedPage.map(cloneImageElement); - return nextImages; - }); - }, []); - - const handleResetEdits = useCallback(() => { - if (!loadedDocument) { - return; - } - resetToDocument(loadedDocument, groupingMode); - setErrorMessage(null); - }, [groupingMode, loadedDocument, resetToDocument]); - - const buildPayload = useCallback(() => { - if (!loadedDocument) { - return null; - } - - const updatedDocument = restoreGlyphElements( - loadedDocument, - groupsByPage, - imagesByPageRef.current, - originalImagesRef.current, - forceSingleTextElement, - ); - const baseName = sanitizeBaseName( - fileName || loadedDocument.metadata?.title || undefined, - ); - return { - document: updatedDocument, - filename: `${baseName}.json`, - }; - }, [fileName, forceSingleTextElement, groupsByPage, loadedDocument]); - - const handleDownloadJson = useCallback(() => { - const payload = buildPayload(); - if (!payload) { - return; - } - - const { document, filename } = payload; - const serialized = JSON.stringify(document); - downloadTextAsFile(serialized, filename, "application/json"); - - if (onComplete) { - const exportedFile = new File([serialized], filename, { - type: "application/json", - }); - onComplete([exportedFile]); - } - }, [buildPayload, onComplete]); - - const handleGeneratePdf = useCallback( - async (skipComplete = false) => { - try { - setIsGeneratingPdf(true); - - const ensureImagesForPages = async (pageIndices: number[]) => { - const uniqueIndices = Array.from(new Set(pageIndices)).filter( - (index) => index >= 0, - ); - if (uniqueIndices.length === 0) { - return; - } - - for (const index of uniqueIndices) { - if (!loadedImagePagesRef.current.has(index)) { - await loadImagesForPage(index); - } - } - - const maxWaitTime = 15000; - const pollInterval = 150; - const startWait = Date.now(); - while (Date.now() - startWait < maxWaitTime) { - const allLoaded = uniqueIndices.every( - (index) => - loadedImagePagesRef.current.has(index) && - imagesByPageRef.current[index] !== undefined, - ); - const anyLoading = uniqueIndices.some((index) => - loadingImagePagesRef.current.has(index), - ); - if (allLoaded && !anyLoading) { - return; - } - await new Promise((resolve) => setTimeout(resolve, pollInterval)); - } - - const missing = uniqueIndices.filter( - (index) => !loadedImagePagesRef.current.has(index), - ); - if (missing.length > 0) { - throw new Error( - `Failed to load images for pages ${missing.map((i) => i + 1).join(", ")}`, - ); - } - }; - - const currentDoc = loadedDocumentRef.current; - const totalPages = currentDoc?.pages?.length ?? 0; - const dirtyPageIndices = dirtyPages - .map((isDirty, index) => (isDirty ? index : -1)) - .filter((index) => index >= 0); - - const canUseIncremental = - isLazyMode && cachedJobId && dirtyPageIndices.length > 0; - - if (canUseIncremental) { - await ensureImagesForPages(dirtyPageIndices); - - try { - const payload = buildPayload(); - if (!payload) { - throw new Error("Failed to build payload"); - } - - const { document, filename } = payload; - const dirtyPageSet = new Set(dirtyPageIndices); - const partialPages = - document.pages?.filter((_, index) => dirtyPageSet.has(index)) ?? - []; - - const partialDocument: PdfJsonDocument = { - // Incremental export only needs changed pages. - // Fonts/resources/content streams are resolved from server-side cache. - pages: partialPages, - }; - - const baseName = sanitizeBaseName(filename).replace( - /-edited$/u, - "", - ); - const expectedName = `${baseName || "document"}.pdf`; - const response = await apiClient.post( - `/api/v1/convert/pdf/text-editor/partial/${cachedJobIdRef.current}?filename=${encodeURIComponent(expectedName)}`, - partialDocument, - { - responseType: "blob", - }, - ); - - const contentDisposition = - response.headers?.["content-disposition"] ?? ""; - const detectedName = getFilenameFromHeaders(contentDisposition); - const downloadName = detectedName || expectedName; - - downloadBlob(response.data, downloadName); - - if (onComplete && !skipComplete) { - const pdfFile = new File([response.data], downloadName, { - type: "application/pdf", - }); - onComplete([pdfFile]); - } - setErrorMessage(null); - return; - } catch (incrementalError) { - if (isLazyMode && cachedJobIdRef.current) { - throw new Error( - "Incremental export failed for cached document. Please reload and retry.", - { - cause: incrementalError, - }, - ); - } - console.warn( - "[handleGeneratePdf] Incremental export failed, falling back to full export", - incrementalError, - ); - } - } - - if (isLazyMode && totalPages > 0) { - const allPageIndices = Array.from( - { length: totalPages }, - (_, index) => index, - ); - await ensureImagesForPages(allPageIndices); - } - - const payload = buildPayload(); - if (!payload) { + const runSave = useCallback( + async (download: boolean) => { + const doc = store.document; + if (!doc || savingRef.current) return; + // Re-evaluate on EVERY save: the ack only covers the exact risk set + // the user saw. A new risk appearing later must warn again. + const risks = detectSaveRisks(doc); + if (hasSaveRisks(risks)) { + const sig = JSON.stringify(risks); + const acked = ackedRiskRef.current; + if (!acked || acked.doc !== doc || acked.sig !== sig) { + pendingDownloadRef.current = download; + setSaveRisks(risks); return; } - - const { document, filename } = payload; - const serialized = JSON.stringify(document); - const jsonFile = new File([serialized], filename, { - type: "application/json", - }); - - const formData = new FormData(); - formData.append("fileInput", jsonFile); - const response = await apiClient.post( - CONVERSION_ENDPOINTS["text-editor-pdf"], - formData, - { - responseType: "blob", - }, - ); - - const contentDisposition = - response.headers?.["content-disposition"] ?? ""; - const detectedName = getFilenameFromHeaders(contentDisposition); - const baseName = sanitizeBaseName(filename).replace(/-edited$/u, ""); - const downloadName = detectedName || `${baseName || "document"}.pdf`; - - downloadBlob(response.data, downloadName); - - if (onComplete && !skipComplete) { - const pdfFile = new File([response.data], downloadName, { - type: "application/pdf", - }); - onComplete([pdfFile]); - } - setErrorMessage(null); - } catch (error) { - console.error("Failed to convert JSON back to PDF", error); - const message = - (isAxiosError(error) ? error.response?.data : undefined) || - (error instanceof Error ? error.message : undefined) || - t( - "pdfTextEditor.errors.pdfConversion", - "Unable to convert the edited JSON back into a PDF.", - ); - const msgString = - typeof message === "string" ? message : String(message); - setErrorMessage(msgString); - if (onError) { - onError(msgString); - } - } finally { - setIsGeneratingPdf(false); } + await doSave(download); }, - [ - buildPayload, - cachedJobId, - dirtyPages, - isLazyMode, - loadImagesForPage, - onComplete, - onError, - t, - ], + [store, doSave], ); - // Save changes to workbench (replaces the original file with edited version) - const handleSaveToWorkbench = useCallback(async () => { - setIsSavingToWorkbench(true); + const handleSave = useCallback(() => void runSave(false), [runSave]); + const handleDownload = useCallback(() => void runSave(true), [runSave]); - try { - if (!sourceFileIdRef.current) { - console.warn( - "[PdfTextEditor] No source file ID available for save to workbench", - ); - // Fall back to generating PDF download if no source file - await handleGeneratePdf(true); - return; - } - - const sourceFileId = sourceFileIdRef.current; - const parentStub = selectors.getStirlingFileStub(sourceFileId); - if (!parentStub) { - console.warn( - "[PdfTextEditor] Could not find parent stub for save to workbench", - ); - await handleGeneratePdf(true); - return; - } - - const ensureImagesForPages = async (pageIndices: number[]) => { - const uniqueIndices = Array.from(new Set(pageIndices)).filter( - (index) => index >= 0, - ); - if (uniqueIndices.length === 0) { - return; - } - - for (const index of uniqueIndices) { - if (!loadedImagePagesRef.current.has(index)) { - await loadImagesForPage(index); - } - } - - const maxWaitTime = 15000; - const pollInterval = 150; - const startWait = Date.now(); - while (Date.now() - startWait < maxWaitTime) { - const allLoaded = uniqueIndices.every( - (index) => - loadedImagePagesRef.current.has(index) && - imagesByPageRef.current[index] !== undefined, - ); - const anyLoading = uniqueIndices.some((index) => - loadingImagePagesRef.current.has(index), - ); - if (allLoaded && !anyLoading) { - return; - } - await new Promise((resolve) => setTimeout(resolve, pollInterval)); - } - - const missing = uniqueIndices.filter( - (index) => !loadedImagePagesRef.current.has(index), - ); - if (missing.length > 0) { - throw new Error( - `Failed to load images for pages ${missing.map((i) => i + 1).join(", ")}`, - ); - } + const handleConfirmSaveRisk = useCallback(() => { + const doc = store.document; + if (doc) { + ackedRiskRef.current = { + doc, + sig: JSON.stringify(detectSaveRisks(doc)), }; - - const currentDoc = loadedDocumentRef.current; - const totalPages = currentDoc?.pages?.length ?? 0; - const currentDirtyPages = getDirtyPages( - groupsByPage, - imagesByPage, - originalGroupsRef.current, - originalImagesRef.current, - ); - const dirtyPageIndices = currentDirtyPages - .map((isDirty, index) => (isDirty ? index : -1)) - .filter((index) => index >= 0); - - let pdfBlob: Blob; - let downloadName: string; - - const canUseIncremental = - isLazyMode && cachedJobId && dirtyPageIndices.length > 0; - - if (canUseIncremental) { - await ensureImagesForPages(dirtyPageIndices); - - try { - const payload = buildPayload(); - if (!payload) { - throw new Error("Failed to build payload"); - } - - const { document, filename } = payload; - const dirtyPageSet = new Set(dirtyPageIndices); - const partialPages = - document.pages?.filter((_, index) => dirtyPageSet.has(index)) ?? []; - - const partialDocument: PdfJsonDocument = { - // Incremental export only needs changed pages. - // Fonts/resources/content streams are resolved from server-side cache. - pages: partialPages, - }; - - const baseName = sanitizeBaseName(filename).replace(/-edited$/u, ""); - const expectedName = `${baseName || "document"}.pdf`; - const response = await apiClient.post( - `/api/v1/convert/pdf/text-editor/partial/${cachedJobId}?filename=${encodeURIComponent(expectedName)}`, - partialDocument, - { - responseType: "blob", - }, - ); - - const contentDisposition = - response.headers?.["content-disposition"] ?? ""; - const detectedName = getFilenameFromHeaders(contentDisposition); - downloadName = detectedName || expectedName; - pdfBlob = response.data; - } catch (incrementalError) { - if (isLazyMode && cachedJobId) { - throw new Error( - "Incremental export failed for cached document. Please reload and retry.", - { - cause: incrementalError, - }, - ); - } - console.warn( - "[handleSaveToWorkbench] Incremental export failed, falling back to full export", - incrementalError, - ); - // Fall through to full export - if (isLazyMode && totalPages > 0) { - const allPageIndices = Array.from( - { length: totalPages }, - (_, index) => index, - ); - await ensureImagesForPages(allPageIndices); - } - - const payload = buildPayload(); - if (!payload) { - throw new Error("Failed to build payload", { - cause: incrementalError, - }); - } - - const { document, filename } = payload; - const serialized = JSON.stringify(document); - const jsonFile = new File([serialized], filename, { - type: "application/json", - }); - - const formData = new FormData(); - formData.append("fileInput", jsonFile); - const response = await apiClient.post( - CONVERSION_ENDPOINTS["text-editor-pdf"], - formData, - { - responseType: "blob", - }, - ); - - const contentDisposition = - response.headers?.["content-disposition"] ?? ""; - const detectedName = getFilenameFromHeaders(contentDisposition); - const baseName = sanitizeBaseName(filename).replace(/-edited$/u, ""); - downloadName = detectedName || `${baseName || "document"}.pdf`; - pdfBlob = response.data; - } - } else { - if (isLazyMode && totalPages > 0) { - const allPageIndices = Array.from( - { length: totalPages }, - (_, index) => index, - ); - await ensureImagesForPages(allPageIndices); - } - - const payload = buildPayload(); - if (!payload) { - throw new Error("Failed to build payload"); - } - - const { document, filename } = payload; - const serialized = JSON.stringify(document); - const jsonFile = new File([serialized], filename, { - type: "application/json", - }); - - const formData = new FormData(); - formData.append("fileInput", jsonFile); - const response = await apiClient.post( - CONVERSION_ENDPOINTS["text-editor-pdf"], - formData, - { - responseType: "blob", - }, - ); - - const contentDisposition = - response.headers?.["content-disposition"] ?? ""; - const detectedName = getFilenameFromHeaders(contentDisposition); - const baseName = sanitizeBaseName(filename).replace(/-edited$/u, ""); - downloadName = detectedName || `${baseName || "document"}.pdf`; - pdfBlob = response.data; - } - - // Create the new PDF file - const pdfFile = new File([pdfBlob], downloadName, { - type: "application/pdf", - }); - - // Create StirlingFile and stub for the output - const { stirlingFiles, stubs } = await createStirlingFilesAndStubs( - [pdfFile], - parentStub, - "pdfTextEditor", - ); - - // Replace the original file with the edited version - await consumeFiles([sourceFileId], stirlingFiles, stubs); - - // Update the source file ID to point to the new file - sourceFileIdRef.current = stubs[0].id; - - // Clear the unsaved changes flag - this will trigger the useEffect to navigate - // once React has processed the state update - navigationActions.setHasUnsavedChanges(false); - setErrorMessage(null); - - // Set flag to trigger navigation after state update is processed - setShouldNavigateAfterSave(true); - } catch (error) { - console.error("Failed to save to workbench", error); - const message = - (isAxiosError(error) ? error.response?.data : undefined) || - (error instanceof Error ? error.message : undefined) || - t( - "pdfTextEditor.errors.pdfConversion", - "Unable to save changes to workbench.", - ); - const msgString = typeof message === "string" ? message : String(message); - setErrorMessage(msgString); - if (onError) { - onError(msgString); - } - } finally { - setIsSavingToWorkbench(false); } - }, [ - buildPayload, - cachedJobId, - consumeFiles, - groupsByPage, - handleGeneratePdf, - imagesByPage, - isLazyMode, - loadImagesForPage, - navigationActions, - onError, - selectors, - t, - ]); + setSaveRisks(null); + void doSave(pendingDownloadRef.current); + }, [store, doSave]); - const requestPagePreview = useCallback( - async (pageIndex: number, scale: number) => { - if (!hasVectorPreview || !pdfDocumentRef.current) { - return; - } - const currentToken = previewRequestIdRef.current; - const recordedScale = previewScaleRef.current.get(pageIndex); - if ( - pagePreviewsRef.current.has(pageIndex) && - recordedScale !== undefined && - Math.abs(recordedScale - scale) < 0.05 - ) { - return; - } - if (previewRenderingRef.current.has(pageIndex)) { - return; - } - previewRenderingRef.current.add(pageIndex); + const handleInsertImage = useCallback( + async (file: File) => { + const doc = store.document; + if (!doc) return; + // Decode via an element rather than createImageBitmap: the latter + // lacks codec support in some environments. + let decoded: { data: ImageData; width: number; height: number }; try { - const page = await pdfDocumentRef.current.getPage(pageIndex + 1); - const viewport = page.getViewport({ scale: Math.max(scale, 0.5) }); - const canvas = document.createElement("canvas"); - canvas.width = viewport.width; - canvas.height = viewport.height; - const context = canvas.getContext("2d"); - if (!context) { - page.cleanup(); - return; - } - await page.render({ canvas, canvasContext: context, viewport }).promise; - + decoded = await decodeImageFile(file); + } catch (err) { + store.setError( + err instanceof Error + ? err.message + : t( + "pdfTextEditor.error.decodeImage", + "Could not decode the selected image.", + ), + ); + return; + } + // Keep the original JPEG bytes so the insert embeds them as-is + // (DCTDecode) instead of re-encoding decoded RGBA - far smaller output. + let jpegBytes: Uint8Array | undefined; + if (file.type === "image/jpeg") { try { - const textContent = await page.getTextContent(); - const maskMarginX = 0; - const maskMarginTop = 0; - const maskMarginBottom = Math.max(3 * scale, 3); - context.save(); - context.globalCompositeOperation = "destination-out"; - context.fillStyle = "#000000"; - for (const item of textContent.items) { - // Skip TextMarkedContent items, only process TextItem - if (!("transform" in item)) continue; - - const transform = Util.transform( - viewport.transform, - item.transform, - ); - const a = transform[0]; - const b = transform[1]; - const c = transform[2]; - const d = transform[3]; - const e = transform[4]; - const f = transform[5]; - const angle = Math.atan2(b, a); - - const width = (item.width || 0) * viewport.scale + maskMarginX * 2; - const fontHeight = Math.hypot(c, d); - const rawHeight = item.height - ? item.height * viewport.scale - : fontHeight; - const height = Math.max( - rawHeight + maskMarginTop + maskMarginBottom, - fontHeight + maskMarginTop + maskMarginBottom, - ); - const baselineOffset = height - maskMarginBottom; - - context.save(); - context.translate(e, f); - context.rotate(angle); - context.fillRect(-maskMarginX, -baselineOffset, width, height); - context.restore(); - } - context.restore(); - } catch (textError) { - console.warn( - "[PdfTextEditor] Failed to strip text from preview", - textError, - ); + jpegBytes = new Uint8Array(await file.arrayBuffer()); + // The decode above APPLIES EXIF orientation; the raw bytes + // don't. + if (jpegExifOrientation(jpegBytes) !== 1) jpegBytes = undefined; + } catch { + jpegBytes = undefined; // fall back to the bitmap path } - - // Also mask out images to prevent ghost/shadow images when they're moved - try { - const pageImages = imagesByPage[pageIndex] ?? []; - if (pageImages.length > 0) { - context.save(); - context.globalCompositeOperation = "destination-out"; - context.fillStyle = "#000000"; - for (const image of pageImages) { - if (!image) continue; - // Get image bounds in PDF coordinates - const left = image.left ?? image.x ?? 0; - const bottom = image.bottom ?? image.y ?? 0; - const width = - image.width ?? Math.max((image.right ?? left) - left, 0); - const height = - image.height ?? Math.max((image.top ?? bottom) - bottom, 0); - const _right = left + width; - const top = bottom + height; - - // Convert to canvas coordinates (PDF origin is bottom-left, canvas is top-left) - const canvasX = left * scale; - const canvasY = canvas.height - top * scale; - const canvasWidth = width * scale; - const canvasHeight = height * scale; - context.fillRect(canvasX, canvasY, canvasWidth, canvasHeight); - } - context.restore(); - } - } catch (imageError) { - console.warn( - "[PdfTextEditor] Failed to strip images from preview", - imageError, - ); - } - const dataUrl = canvas.toDataURL("image/png"); - page.cleanup(); - if (previewRequestIdRef.current !== currentToken) { - return; - } - previewScaleRef.current.set(pageIndex, scale); - setPagePreviews((prev) => { - const next = new Map(prev); - next.set(pageIndex, dataUrl); - return next; - }); - } catch (error) { - console.warn("[PdfTextEditor] Failed to render page preview", error); - } finally { - previewRenderingRef.current.delete(pageIndex); + } + // The document may have been reloaded while the image decoded; bail + // rather than insert against geometry from the wrong document. + if (store.document !== doc) return; + // Insert onto the page currently in view, read from fresh store state. + const pages = store.getState().pages; + const visibleIndex = visiblePageNumber(); + const page = pages.find((p) => p.pageIndex === visibleIndex) ?? pages[0]; + if (!page) return; + const w = page.width * INSERTED_IMAGE_RATIO; + const h = w * (decoded.height / decoded.width); + // Centre in the VISIBLE (display) page, then invert the CropBox/rotation + // transform to raw PDF space (commands store raw coords). + const ll = DisplayTransform.fromData(page.display).invert( + (page.width - w) / 2, + (page.height - h) / 2, + ); + const cmd = new InsertImageCommand({ + pageIndex: page.pageIndex, + rgba: decoded.data.data, + pixelWidth: decoded.width, + pixelHeight: decoded.height, + x: ll.x, + y: ll.y, + width: w, + height: h, + jpegBytes, + }); + store.dispatch(cmd); + if (cmd.insertedImageId) { + store.selection.selectImage(cmd.insertedImageId); + } else { + store.setError( + t( + "pdfTextEditor.error.insertImage", + "Could not insert the selected image.", + ), + ); } }, - [hasVectorPreview, imagesByPage], + [store, t], ); - // Re-group text when grouping mode changes without forcing a full reload - useEffect(() => { - const currentDocument = loadedDocumentRef.current; - if (currentDocument) { - resetToDocument(currentDocument, groupingMode); - } - }, [groupingMode, resetToDocument]); + /** Text of the object-level selection, or null when it carries none. */ + const getSelectedText = useCallback((): string | null => { + const ids = store.selection.value.runIds; + if (ids.length === 0) return null; + const texts = store + .getState() + .pages.flatMap((p) => p.runs) + .filter((r) => ids.includes(r.id)) + .map((r) => r.text); + return texts.length === 0 ? null : texts.join("\n"); + }, [store]); - const viewData = useMemo( - () => ({ - document: loadedDocument, - groupsByPage, - imagesByPage, - pagePreviews, - selectedPage, - dirtyPages, - hasDocument, - hasVectorPreview, - fileName, - errorMessage, - isGeneratingPdf, - isSavingToWorkbench, - isConverting, - conversionProgress, - hasChanges, - forceSingleTextElement, - groupingMode, - autoScaleText, - onAutoScaleTextChange: setAutoScaleText, - requestPagePreview, - onSelectPage: handleSelectPage, - onGroupEdit: handleGroupTextChange, - onGroupDelete: handleGroupDelete, - onImageTransform: handleImageTransform, - onImageReset: handleImageReset, - onReset: handleResetEdits, - onDownloadJson: handleDownloadJson, - onGeneratePdf: handleGeneratePdf, - onGeneratePdfForNavigation: async () => { - // Generate PDF without triggering tool completion - await handleGeneratePdf(true); - }, - onSaveToWorkbench: handleSaveToWorkbench, - onForceSingleTextElementChange: setForceSingleTextElement, - onGroupingModeChange: setGroupingMode, - onMergeGroups: handleMergeGroups, - onUngroupGroup: handleUngroupGroup, - onLoadFile: handleLoadFileFromDropzone, - }), - [ - handleMergeGroups, - handleUngroupGroup, - handleImageTransform, - handleSaveToWorkbench, - imagesByPage, - isSavingToWorkbench, - pagePreviews, - dirtyPages, - errorMessage, - fileName, - groupsByPage, - handleDownloadJson, - handleGeneratePdf, - handleGroupTextChange, - handleGroupDelete, - handleImageReset, - handleResetEdits, - handleSelectPage, - hasChanges, - hasDocument, - hasVectorPreview, - isGeneratingPdf, - isConverting, - conversionProgress, - loadedDocument, - selectedPage, - forceSingleTextElement, - groupingMode, - autoScaleText, - requestPagePreview, - setForceSingleTextElement, - handleLoadFileFromDropzone, - ], - ); + const hasSelection = useCallback(() => { + const s = store.selection.value; + return s.runIds.length > 0 || s.imageIds.length > 0; + }, [store]); - const latestViewDataRef = useRef(viewData); - latestViewDataRef.current = viewData; - - // Trigger initial image loading in lazy mode - useEffect(() => { - if (isLazyMode && loadedDocument) { - void loadImagesForPage(selectedPage); - } - }, [isLazyMode, loadedDocument, selectedPage, loadImagesForPage]); - - useEffect(() => { - if (!autoLoadFile) { - autoLoadKeyRef.current = null; - sourceFileIdRef.current = null; - return; - } - - if (navigationState.selectedTool !== "pdfTextEditor") { - return; - } - - const fileKey = getAutoLoadKey(autoLoadFile); - if (autoLoadKeyRef.current === fileKey) { - return; - } - - autoLoadKeyRef.current = fileKey; - // Capture the source file ID for save-to-workbench functionality - sourceFileIdRef.current = autoLoadFile.fileId ?? null; - void handleLoadFile(autoLoadFile); - }, [autoLoadFile, navigationState.selectedTool, handleLoadFile]); - - // Auto-navigate to workbench when tool is selected - const hasAutoOpenedWorkbenchRef = useRef(false); - useEffect(() => { - if (navigationState.selectedTool !== "pdfTextEditor") { - hasAutoOpenedWorkbenchRef.current = false; - return; - } - - if (hasAutoOpenedWorkbenchRef.current) { - return; - } - - hasAutoOpenedWorkbenchRef.current = true; - // Use timeout to ensure registration effect has run first - setTimeout(() => { - navigationActions.setWorkbench(WORKBENCH_ID); - }, 0); - }, [navigationActions, navigationState.selectedTool]); - - // Register workbench view (re-runs when dependencies change) - useEffect(() => { - registerCustomWorkbenchView({ - id: WORKBENCH_VIEW_ID, - workbenchId: WORKBENCH_ID, - label: viewLabel, - icon: , - component: PdfTextEditorView, - }); - setLeftPanelView("toolContent"); - setCustomWorkbenchViewData(WORKBENCH_VIEW_ID, latestViewDataRef.current); - }, [ - registerCustomWorkbenchView, - setCustomWorkbenchViewData, - setLeftPanelView, - viewLabel, - ]); - - // Cleanup ONLY on component unmount (not on re-renders) - useEffect(() => { - return () => { - // Clear backend cache when leaving the tool - const jobId = cachedJobIdRef.current; - if (jobId) { - console.log( - `[PdfTextEditor] Cleaning up cached document on unmount: ${jobId}`, + // Paste: create a fresh InsertTextCommand on the currently-visible page, + // positioned in roughly the centre. + const insertPastedText = useCallback( + (text: string, stripFormatting: boolean) => { + const doc = store.document; + if (!doc) return; + // `stripFormatting` is honoured by normalising line endings and + // collapsing leading/trailing whitespace. + const normalised = stripFormatting + ? text.replace(/\r\n?/g, "\n").trim() + : text.replace(/\r\n?/g, "\n"); + if (!normalised) return; + // Find the visible page (Ctrl+End behaves the same way). + const stage = document.querySelector( + '[data-testid="pdf-editor-stage"]', + ); + const stageRect = stage?.getBoundingClientRect(); + const stageCentreY = stageRect ? stageRect.top + stageRect.height / 2 : 0; + let pageIndex = 0; + let bestDist = Infinity; + for (const p of doc.loadedPages()) { + const el = document.querySelector( + `[data-testid="pdf-editor-page-${p.index}"]`, ); - apiClient - .post(`/api/v1/convert/pdf/text-editor/clear-cache/${jobId}`) - .catch((error) => { - console.warn( - "[PdfTextEditor] Failed to clear cache on unmount:", - error, - ); - }); + if (!el) continue; + const r = el.getBoundingClientRect(); + const centre = r.top + r.height / 2; + const dist = Math.abs(centre - stageCentreY); + if (dist < bestDist) { + bestDist = dist; + pageIndex = p.index; + } } - clearCustomWorkbenchViewData(WORKBENCH_VIEW_ID); - unregisterCustomWorkbenchView(WORKBENCH_VIEW_ID); - setLeftPanelView("toolPicker"); - }; - }, []); // Empty deps = cleanup only on unmount + const page = doc.page(pageIndex); + // Position roughly at the page centre, biased toward the upper third so + // multi-line paste has room to flow downward. + const anchor = page.display.invert( + page.width / 2 - 80, + page.height * 0.55, + ); + const cmd = new InsertTextCommand({ + pageIndex, + x: anchor.x, + y: anchor.y, + text: normalised, + }); + store.dispatch(cmd); + if (cmd.insertedRunId) store.selection.selectOne(cmd.insertedRunId); + }, + [store], + ); - // Note: Compare tool doesn't auto-force workbench, and neither should we - // The workbench should be set when the tool is selected via proper channels - // (tool registry, tool picker, etc.) - not forced here + const handleFindNext = useCallback((reverse: boolean) => { + setFindOpen(true); + const button = document.querySelector( + reverse + ? '[data-testid="pdf-editor-find-prev"]' + : '[data-testid="pdf-editor-find-next"]', + ); + button?.click(); + }, []); - const lastSentViewDataRef = useRef(null); + const handleEscape = useCallback(() => { + store.selection.clear(); + store.setMode("select"); + setHelpOpen(false); + setFindOpen(false); + }, [store]); - useEffect(() => { - if (lastSentViewDataRef.current === viewData) { - return; + const handleUngroupSelection = useCallback(() => { + const doc = store.document; + if (!doc) return; + const ids = store.selection.value.runIds; + // Snapshot the target runs first - dispatching mutates page.runs, and + // the ungroup replaces the paragraph run with per-line runs. + const targets: Array<{ pageIndex: number; runId: string }> = []; + for (const pageIdx of doc.loadedPages().map((p) => p.index)) { + for (const r of doc.page(pageIdx).runs) { + if (!ids.includes(r.id)) continue; + if (r.paragraphMemberPtrs.length < 2) continue; + targets.push({ pageIndex: pageIdx, runId: r.id }); + } } - lastSentViewDataRef.current = viewData; - setCustomWorkbenchViewData(WORKBENCH_VIEW_ID, viewData); - }, [setCustomWorkbenchViewData, viewData]); + const resultIds: string[] = []; + for (const t of targets) { + const cmd = new UngroupParagraphCommand(t); + store.dispatch(cmd); + resultIds.push(...cmd.resultRunIds); + } + // Reconcile selection against the new run model so the toolbar keeps + // acting on real runs instead of the now-removed paragraph ids. + if (resultIds.length > 0) store.selection.selectMany(resultIds); + else store.selection.clear(); + }, [store]); - // Render the sidebar with settings while editing happens in the custom workbench view. - return ; -}; + const handleMergeSelection = useCallback(() => { + const doc = store.document; + if (!doc) return; + const selectedIds = new Set(store.selection.value.runIds); + if (selectedIds.size < 2) return; + const byPage = new Map(); + for (const page of doc.loadedPages()) { + for (const r of page.runs) { + if (!selectedIds.has(r.id)) continue; + const list = byPage.get(r.pageIndex) ?? []; + list.push(r.id); + byPage.set(r.pageIndex, list); + } + } + // Collect every page's new representative, then select them all once - + // selecting inside the loop left only the last page's merge selected. + const reps: string[] = []; + for (const [pageIndex, runIds] of byPage) { + if (runIds.length < 2) continue; + const cmd = new MergeRunsCommand({ pageIndex, runIds }); + store.dispatch(cmd); + if (cmd.representativeRunId) reps.push(cmd.representativeRunId); + } + if (reps.length > 0) store.selection.selectMany(reps); + }, [store]); -(PdfTextEditor as ToolComponent).tool = () => { - throw new Error("PDF Text Editor does not support automation operations."); -}; + useEditorKeyboardShortcuts({ + store, + onUndo: useCallback(() => store.undo(), [store]), + onRedo: useCallback(() => store.redo(), [store]), + onSave: handleSave, + onDelete: sel.deleteSelection, + onDuplicate: sel.duplicateFirstSelected, + onSelectAll: useCallback(() => { + // Pages past the eager window hold no runs until they scroll into view, + // so reading the model as-is would select only part of the document. + ensureAllPagesRead(store); + const ids = store + .getState() + .pages.flatMap((p) => p.runs.map((r) => r.id)); + if (ids.length > 0) store.selection.selectMany(ids); + }, [store]), + onToggleHelp: useCallback(() => setHelpOpen((v) => !v), []), + onOpenFind: useCallback(() => setFindOpen(true), []), + onFindNext: handleFindNext, + onEscape: handleEscape, + onMergeSelection: handleMergeSelection, + }); -(PdfTextEditor as ToolComponent).getDefaultParameters = () => ({ - groups: [], -}); + useEditorClipboard({ + hasSelection, + getSelectedText, + deleteSelection: sel.deleteSelection, + insertPastedText, + }); -export default PdfTextEditor as ToolComponent; + const canGroup = selection.runIds.length >= 2; + const canUngroup = (() => { + if (selection.runIds.length !== 1) return false; + const run = state.pages + .flatMap((p) => p.runs) + .find((r) => r.id === selection.runIds[0]); + return !!run && (run.paragraphLineCount ?? 0) > 1; + })(); + const onPickPdf = useCallback( + (file: File) => { + setOpenedFileName(file.name); + // Dropped/picked from disk: no workbench file to replace yet, but claim + // it so a later workbench arrival cannot auto-open over these edits. + adoptFile(file); + setSourceFile(null); + void load(file); + }, + [adoptFile, load, setSourceFile], + ); + + const handleSubmitPassword = useCallback( + (password: string) => { + const file = store.pendingPasswordFile; + if (file) void load(file, password); + }, + [store, load], + ); + + const handleCancelPassword = useCallback( + () => store.clearPasswordPrompt(), + [store], + ); + + return ( + + {state.error && ( + + {state.error} + + )} + + {findOpen && state.hasDocument && ( + setFindOpen(false)} + /> + )} + setHelpOpen(false)} /> + setSaveRisks(null)} + /> + + store.setGroupingMode(mode)} + onSetWidthMode={(m) => store.setWidthMode(m)} + onSetShowRulers={(show) => store.setShowRulers(show)} + onOpenFind={() => setFindOpen(true)} + onShowHelp={() => setHelpOpen(true)} + addTextArmed={state.mode === "addText"} + onToggleAddText={() => + store.setMode( + store.getState().mode === "addText" ? "select" : "addText", + ) + } + onPickImage={() => + document + .querySelector( + '[data-testid="pdf-editor-image-input"]', + ) + ?.click() + } + /> + {state.hasDocument && ( + + )} + + ); +} + +/** Decode an image File to RGBA via an element + canvas. */ +function decodeImageFile( + file: File, +): Promise<{ data: ImageData; width: number; height: number }> { + return new Promise((resolve, reject) => { + const url = URL.createObjectURL(file); + const img = new Image(); + img.onload = () => { + try { + const width = img.naturalWidth || img.width; + const height = img.naturalHeight || img.height; + const canvas = document.createElement("canvas"); + canvas.width = width; + canvas.height = height; + const ctx = canvas.getContext("2d"); + if (!ctx) { + reject(new Error("Canvas 2D context unavailable")); + return; + } + ctx.drawImage(img, 0, 0); + resolve({ data: ctx.getImageData(0, 0, width, height), width, height }); + } catch (e) { + reject(e instanceof Error ? e : new Error(String(e))); + } finally { + URL.revokeObjectURL(url); + } + }; + img.onerror = () => { + URL.revokeObjectURL(url); + reject(new Error("Could not decode the selected image.")); + }; + img.src = url; + }); +} diff --git a/frontend/editor/src/core/tools/pdfTextEditor/__tests__/BackendResolver.test.ts b/frontend/editor/src/core/tools/pdfTextEditor/__tests__/BackendResolver.test.ts new file mode 100644 index 0000000000..c0d0e532bc --- /dev/null +++ b/frontend/editor/src/core/tools/pdfTextEditor/__tests__/BackendResolver.test.ts @@ -0,0 +1,357 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +/** Regression coverage for `BackendResolver`'s HTTP transport. */ + +// Mock apiClient BEFORE BackendResolver imports it. +vi.mock("@app/services/apiClient", () => ({ + default: { post: vi.fn() }, +})); + +// Stub the document serializer so the prewarm path can produce PDF bytes +// without a real PDFium file-writer. +vi.mock("@app/tools/pdfTextEditor/pdfium/PdfiumSave", () => ({ + PdfiumSave: { serialize: vi.fn(() => new Uint8Array([0, 1, 2, 3])) }, +})); + +import apiClient from "@app/services/apiClient"; +import { + BackendResolver, + prewarmBackendCacheForPage, + resetBackendResolverCaches, + _clearBackendCacheForTests, + _clearPrewarmGuardForTests, +} from "@app/tools/pdfTextEditor/charcode/BackendResolver"; +import { + primeFontGlyphMap, + _clearCmapCacheForTests, +} from "@app/tools/pdfTextEditor/charcode/CmapResolver"; +import { sha256Hex } from "@app/tools/pdfTextEditor/util/sha256"; +import type { ResolverContext } from "@app/tools/pdfTextEditor/charcode/CharcodeStrategy"; + +const post = apiClient.post as unknown as ReturnType; + +// Minimal stub for ResolverContext. +const fakeCtx: ResolverContext = { + module: {} as unknown as ResolverContext["module"], + pagePtr: 0, + docPtr: 0, +}; + +// Build a fake PDFium module that renders a single char on a page so the +// prewarm text-walk finds exactly one probe to fire. `char` is the Unicode. +function makeFakeModule(char: string, fontPtr: number) { + const cp = char.codePointAt(0) ?? 0; + const TEXT_PAGE = 555; + const TEXT_OBJ = 777; + return { + FPDFText_LoadPage: vi.fn(() => TEXT_PAGE), + FPDFText_ClosePage: vi.fn(), + FPDFText_CountChars: vi.fn(() => 1), + FPDFText_GetUnicode: vi.fn(() => cp), + FPDFText_GetTextObject: vi.fn(() => TEXT_OBJ), + FPDFTextObj_GetFont: vi.fn(() => fontPtr), + } as unknown as ResolverContext["module"]; +} + +// Fake PDFium module rendering an arbitrary sequence of glyphs, each with its +// own font handle. `glyphs` is a list of [char, fontPtr] in page reading order. +function makeFakeModulePage(glyphs: Array<[string, number]>) { + const TEXT_PAGE = 555; + const OBJ_BASE = 1000; + return { + FPDFText_LoadPage: vi.fn(() => TEXT_PAGE), + FPDFText_ClosePage: vi.fn(), + FPDFText_CountChars: vi.fn(() => glyphs.length), + FPDFText_GetUnicode: vi.fn( + (_tp: number, i: number) => glyphs[i][0].codePointAt(0) ?? 0, + ), + FPDFText_GetTextObject: vi.fn((_tp: number, i: number) => OBJ_BASE + i), + FPDFTextObj_GetFont: vi.fn((obj: number) => glyphs[obj - OBJ_BASE][1]), + } as unknown as ResolverContext["module"]; +} + +/** Poll until `predicate` is true (async prefetch settles) or time out. */ +async function waitUntil(predicate: () => boolean): Promise { + for (let i = 0; i < 100; i++) { + if (predicate()) return; + await new Promise((r) => setTimeout(r, 1)); + } +} + +// Install a fake editor document on window so `prewarmBackendCacheForPage` +// resolves a page + module instead of bailing on "no-editor-ctx". +function installEditorDocument( + module: ResolverContext["module"], + pagePtr: number, + docPtr: number, +) { + const doc = { + module, + docPtr, + loadedPages: () => [{ index: 0, pagePtr, docPtr }], + }; + (window as unknown as { __editor_store?: unknown }).__editor_store = { + document: doc, + }; +} + +beforeEach(() => { + post.mockReset(); + resetBackendResolverCaches(); + _clearBackendCacheForTests(); + _clearPrewarmGuardForTests(); + _clearCmapCacheForTests(); + delete (window as unknown as { __editor_store?: unknown }).__editor_store; +}); + +afterEach(() => { + post.mockReset(); + vi.restoreAllMocks(); + delete (window as unknown as { __editor_store?: unknown }).__editor_store; +}); + +describe("BackendResolver", () => { + describe("HTTP transport via shared apiClient (regression #111)", () => { + it("routes the encode POST through apiClient.post with the suppressErrorToast and skipAuthRedirect config flags", async () => { + // One glyph 'M' on the page, rendered by font handle 7. Prewarm walks + // the page, finds one probe, serializes the doc (mocked) and POSTs. + const module = makeFakeModule("M", 7); + installEditorDocument(module, 9001, 4242); + post.mockResolvedValueOnce({ data: { charcodes: [182] } }); + + await prewarmBackendCacheForPage(0); + + expect(post).toHaveBeenCalledTimes(1); + expect(post).toHaveBeenCalledWith( + "/api/v1/general/pdf-text-editor/encode-charcodes", + expect.objectContaining({ + pdfBase64: expect.any(String), + pageIndex: 0, + locatorChar: "M", + text: expect.stringContaining("M"), + }), + // Top-level axios config, NOT headers: handleHttpError reads + // `error.config.`, so the header spelling was inert. + { suppressErrorToast: true, skipAuthRedirect: true }, + ); + }); + + it("never calls raw fetch() (must go through apiClient)", async () => { + const fetchSpy = vi.spyOn(globalThis, "fetch"); + const module = makeFakeModule("A", 3); + installEditorDocument(module, 9002, 4242); + post.mockResolvedValueOnce({ data: { charcodes: [65] } }); + + await prewarmBackendCacheForPage(0); + + expect(post).toHaveBeenCalledTimes(1); + expect(fetchSpy).not.toHaveBeenCalled(); + }); + + it("swallows an HTTP/network error from apiClient.post (postCharcodes -> null, no throw)", async () => { + const module = makeFakeModule("Z", 5); + installEditorDocument(module, 9003, 4242); + // A rejected probe (e.g. a 401) must not propagate: prewarm is + // best-effort and postCharcodes' catch returns null. + post.mockRejectedValueOnce(new Error("401")); + + await expect(prewarmBackendCacheForPage(0)).resolves.toBeUndefined(); + expect(post).toHaveBeenCalledTimes(1); + }); + }); + + describe("prewarm batching + cross-font cache key", () => { + const ENDPOINT = "/api/v1/general/pdf-text-editor/encode-charcodes"; + + it("batches all of a font's page chars into ONE request (H3)", async () => { + // Two glyphs 'A','B' both rendered by font 7. Prewarm must fire ONE + // request carrying "AB", not one per char. + const module = makeFakeModulePage([ + ["A", 7], + ["B", 7], + ]); + installEditorDocument(module, 9100, 4242); + post.mockResolvedValueOnce({ data: { charcodes: [65, 66] } }); + + await prewarmBackendCacheForPage(0); + + expect(post).toHaveBeenCalledTimes(1); + expect(post).toHaveBeenCalledWith( + ENDPOINT, + expect.objectContaining({ text: expect.stringContaining("AB") }), + // This test's subject is the request body; the transport config is + // pinned in full by the first test in this file. + expect.objectContaining({ suppressErrorToast: true }), + ); + const sent = post.mock.calls[0][1] as { text: string }; + // Characters the page never used must be probed too, or the first time + // the user types one it misses the cache and the font is substituted. + expect(sent.text).toContain("Z"); + expect(sent.text).toContain("9"); + // Both chars cached under font 7 in request order. + const r = new BackendResolver(); + const res = r.resolve(7, "AB", { module, pagePtr: 9100, docPtr: 4242 }); + expect(res?.charcodes).toEqual([65, 66]); + }); + + it("fires one request per distinct font, not per char", async () => { + const module = makeFakeModulePage([ + ["A", 7], + ["B", 8], + ]); + installEditorDocument(module, 9101, 4242); + post.mockResolvedValue({ data: { charcodes: [1] } }); + + await prewarmBackendCacheForPage(0); + + expect(post).toHaveBeenCalledTimes(2); + }); + + it("respects the backend's `missing` list when mapping batched charcodes", async () => { + const module = makeFakeModulePage([ + ["A", 7], + ["B", 7], + ["C", 7], + ]); + installEditorDocument(module, 9102, 4242); + // Backend could encode A and C but not B: charcodes align to the + // NON-missing chars in order. + post.mockResolvedValueOnce({ + data: { charcodes: [65, 67], missing: ["B"] }, + }); + + await prewarmBackendCacheForPage(0); + + const r = new BackendResolver(); + const ctx = { module, pagePtr: 9102, docPtr: 4242 }; + expect(r.resolve(7, "A", ctx)?.charcodes).toEqual([65]); + expect(r.resolve(7, "C", ctx)?.charcodes).toEqual([67]); + // 'B' was reported missing -> cached null -> reported missing, not 67. + const b = r.resolve(7, "B", ctx); + expect(b?.charcodes).toEqual([]); + expect(b?.missing).toEqual(["B"]); + }); + + it("includes the primed font-program hash so the backend can pick the exact subset", async () => { + // The Mangum-CV corruption: PDFium names every "ABCDEF+Garamond" subset + // just "Garamond". + const fontBytes = new Uint8Array([1, 2, 3, 4, 5, 6, 7, 8]); + // Prime the sha cache for font 7 via the CmapResolver's safe-phase read. + const heap = new Uint8Array(1 << 12); + const primeModule = { + FPDFFont_GetFontData: ( + _f: number, + bufferPtr: number, + length: number, + outSizePtr: number, + ) => { + new DataView(heap.buffer).setInt32( + outSizePtr, + fontBytes.length, + true, + ); + if (bufferPtr !== 0 && length > 0) heap.set(fontBytes, bufferPtr); + return true; + }, + pdfium: { + wasmExports: { + malloc: (() => { + let bump = 8; + return (n: number) => { + const p = bump; + bump += n; + return p; + }; + })(), + free: () => {}, + }, + getValue: (ptr: number) => + new DataView(heap.buffer).getInt32(ptr, true), + HEAPU8: heap, + }, + } as unknown as ResolverContext["module"]; + primeFontGlyphMap(7, primeModule); + + const module = makeFakeModule("M", 7); + installEditorDocument(module, 9050, 4242); + post.mockResolvedValueOnce({ data: { charcodes: [33] } }); + + await prewarmBackendCacheForPage(0); + + expect(post).toHaveBeenCalledWith( + "/api/v1/general/pdf-text-editor/encode-charcodes", + expect.objectContaining({ + text: expect.stringContaining("M"), + fontSha256: sha256Hex(fontBytes), + }), + // This test's subject is the request body; the transport config is + // pinned in full by the first test in this file. + expect.objectContaining({ suppressErrorToast: true }), + ); + }); + + it("does not re-POST every keystroke when the queried font differs from the rendering font (H2)", async () => { + // 'A' is rendered by font 7 on the page, but the run is editing under a + // borrowed font handle 99. + const module = makeFakeModulePage([["A", 7]]); + installEditorDocument(module, 9200, 4242); + post.mockResolvedValue({ data: { charcodes: [65] } }); + const r = new BackendResolver(); + const ctx: ResolverContext = { module, pagePtr: 9200, docPtr: 4242 }; + + r.resolve(99, "A", ctx); // miss under font 99 -> kicks prefetch + await waitUntil(() => post.mock.calls.length >= 1); + const callsAfterFirst = post.mock.calls.length; + + // More keystrokes for the same (font 99, 'A'): the null sentinel must + // short-circuit resolve() so no further prefetch fires. + r.resolve(99, "A", ctx); + r.resolve(99, "A", ctx); + await new Promise((res) => setTimeout(res, 5)); + expect(post.mock.calls.length).toBe(callsAfterFirst); + + // The real charcode landed under the rendering font 7. + expect(r.resolve(7, "A", ctx)?.charcodes).toEqual([65]); + }); + }); + + describe("cache semantics", () => { + it("resolve() with an empty text returns null", () => { + const r = new BackendResolver(); + expect(r.resolve(1, "", fakeCtx)).toBeNull(); + }); + + it("resolve() with a 0 font returns null", () => { + const r = new BackendResolver(); + expect(r.resolve(0, "M", fakeCtx)).toBeNull(); + }); + }); + + describe("whitespace is never charcode-reused (mushroom „ bug)", () => { + it("resolve() reports a space as missing and never round-trips it", async () => { + const r = new BackendResolver(); + const result = r.resolve(99, " ", fakeCtx); + // Space must be reported missing, NOT looked up / cached / sent to the + // backend. + expect(result?.missing).toEqual([" "]); + expect(result?.charcodes).toEqual([]); + await Promise.resolve(); + await Promise.resolve(); + expect(post).not.toHaveBeenCalled(); + }); + + it("resolve() splits a mixed chunk: real chars miss the cache, whitespace stays a gap", async () => { + const r = new BackendResolver(); + // "a b" - 'a' and 'b' are genuine cache misses (kick a prefetch), the + // space is reported missing WITHOUT being counted as a prefetch miss. + const result = r.resolve(99, "a b", fakeCtx); + expect(result?.missing).toEqual(["a", " ", "b"]); + expect(result?.charcodes).toEqual([]); + // The prefetch (for 'a') bails before HTTP in this no-window-doc env, + // but crucially the space alone must never be the reason it fires. + const spaceOnly = r.resolve(99, "\t\n ", fakeCtx); + expect(spaceOnly?.charcodes).toEqual([]); + expect(spaceOnly?.missing).toEqual(["\t", "\n", " "]); + }); + }); +}); diff --git a/frontend/editor/src/core/tools/pdfTextEditor/__tests__/BulletGrouping.test.ts b/frontend/editor/src/core/tools/pdfTextEditor/__tests__/BulletGrouping.test.ts new file mode 100644 index 0000000000..aa2e050c72 --- /dev/null +++ b/frontend/editor/src/core/tools/pdfTextEditor/__tests__/BulletGrouping.test.ts @@ -0,0 +1,104 @@ +import { describe, it, expect } from "vitest"; +import { Page } from "@app/tools/pdfTextEditor/model/Page"; +import { TextRun } from "@app/tools/pdfTextEditor/model/TextRun"; +import { LineGrouper } from "@app/tools/pdfTextEditor/pdfium/LineGrouper"; +import { ParagraphGrouper } from "@app/tools/pdfTextEditor/pdfium/ParagraphGrouper"; + +// Reproduces the "Plus Many More" two-column bulleted-list geometry from +// public/samples/Sample.pdf page 3: bullets are separate text objects. + +let ptr = 1000; +function mkRun(opts: { + x: number; + width: number; + f: number; + fs: number; + text: string; +}): TextRun { + return new TextRun({ + id: `r${ptr}`, + pageIndex: 0, + bounds: { x: opts.x, y: opts.f, width: opts.width, height: opts.fs }, + matrix: { a: opts.fs, b: 0, c: 0, d: opts.fs, e: opts.x, f: opts.f }, + text: opts.text, + fontId: "pdf:1:Test", + fontSize: opts.fs, + fill: { r: 0, g: 0, b: 0, a: 255 }, + fontSubset: false, + pdfiumObjPtr: ptr++, + containerPtr: 0, + }); +} + +function group(runs: TextRun[]): TextRun[] { + const page = new Page({ index: 0, pagePtr: 1, width: 600, height: 800 }); + page.setRuns(runs); + page.loaded = true; + LineGrouper.apply(page); + ParagraphGrouper.apply(page); + return page.runs; +} + +describe("bullet-to-item grouping (Plus Many More)", () => { + it("pairs each bullet with its own item and keeps columns separate", () => { + const runs: TextRun[] = [ + // Bottom "Plus Many More" section: bullet fs13.5, item fs11.3, bullet + // baseline ~2.3pt above the item, ~14-17pt indent. + mkRun({ x: 66, width: 3, f: 178.9, fs: 13.5, text: "• " }), + mkRun({ + x: 83, + width: 111, + f: 176.6, + fs: 11.3, + text: "OCR text recognition", + }), + mkRun({ x: 66, width: 3, f: 153.4, fs: 13.5, text: "• " }), + mkRun({ x: 83, width: 80, f: 151.1, fs: 11.3, text: "Compress PDFs" }), + // RIGHT column (gutter ~245pt to the right) + mkRun({ x: 311, width: 3, f: 178.9, fs: 13.5, text: "• " }), + mkRun({ x: 328, width: 101, f: 176.6, fs: 11.3, text: "Flatten forms" }), + mkRun({ x: 311, width: 3, f: 153.4, fs: 13.5, text: "• " }), + mkRun({ + x: 328, + width: 95, + f: 151.1, + fs: 11.3, + text: "PDF/A conversion", + }), + ]; + const out = group(runs); + + // No orphan bullet-only run (the reported bug = a stacked bullet column). + const orphan = out.find( + (r) => /^[\s•]+$/.test(r.text) && (r.text.match(/•/g) ?? []).length >= 2, + ); + expect(orphan, `orphan bullet run: ${orphan?.text}`).toBeUndefined(); + + // Each item's run starts with the bullet and does not swallow a foreign item. + const ocr = out.find((r) => /OCR\s+text/.test(r.text)); + expect(ocr, "OCR run exists").toBeTruthy(); + expect(ocr!.text.trimStart().startsWith("•")).toBe(true); + expect(ocr!.text).not.toMatch(/Flatten/); // not merged across the gutter + + const flatten = out.find((r) => /Flatten\s+forms/.test(r.text)); + expect(flatten, "Flatten run exists").toBeTruthy(); + expect(flatten!.text.trimStart().startsWith("•")).toBe(true); + expect(flatten!.text).not.toMatch(/OCR/); + }); + + it("pairs same-baseline bullets (upper lists) with their item", () => { + const runs: TextRun[] = [ + mkRun({ x: 66, width: 3, f: 642.4, fs: 10.5, text: "• " }), + mkRun({ + x: 80, + width: 91, + f: 642.4, + fs: 10.5, + text: "Merge & split PDFs", + }), + ]; + const out = group(runs); + const merge = out.find((r) => /Merge/.test(r.text)); + expect(merge!.text.trimStart().startsWith("•")).toBe(true); + }); +}); diff --git a/frontend/editor/src/core/tools/pdfTextEditor/__tests__/ChangeZOrderCommand.test.ts b/frontend/editor/src/core/tools/pdfTextEditor/__tests__/ChangeZOrderCommand.test.ts new file mode 100644 index 0000000000..d55cce098c --- /dev/null +++ b/frontend/editor/src/core/tools/pdfTextEditor/__tests__/ChangeZOrderCommand.test.ts @@ -0,0 +1,141 @@ +import { describe, it, expect } from "vitest"; +import { ChangeZOrderCommand } from "@app/tools/pdfTextEditor/commands/ChangeZOrderCommand"; +import { Page } from "@app/tools/pdfTextEditor/model/Page"; +import { ImageObject } from "@app/tools/pdfTextEditor/model/ImageObject"; +import { TextRun } from "@app/tools/pdfTextEditor/model/TextRun"; +import type { EditorDocument } from "@app/tools/pdfTextEditor/model/EditorDocument"; + +// Fake PDFium module backing the page object list with a plain array of +// pointers (index 0 = painted first = bottom, last = top). +function fakeDoc(objs: number[], page: Page): EditorDocument { + const module = { + FPDFPage_CountObjects: () => objs.length, + FPDFPage_GetObject: (_p: number, i: number) => objs[i] ?? 0, + FPDFPage_RemoveObject: (_p: number, ptr: number) => { + const i = objs.indexOf(ptr); + if (i >= 0) objs.splice(i, 1); + return true; + }, + FPDFPage_InsertObjectAtIndex: (_p: number, ptr: number, idx: number) => { + objs.splice(idx, 0, ptr); + return true; + }, + }; + return { module, page: () => page } as unknown as EditorDocument; +} + +function pageWithImage(ptr: number): Page { + const page = new Page({ index: 0, pagePtr: 1, width: 100, height: 100 }); + page.setImages([ + new ImageObject({ + id: "img1", + pageIndex: 0, + pdfiumObjPtr: ptr, + bounds: { x: 0, y: 0, width: 10, height: 10 }, + matrix: { a: 10, b: 0, c: 0, d: 10, e: 0, f: 0 }, + }), + ]); + return page; +} + +describe("ChangeZOrderCommand", () => { + it("bring-to-front moves the object to the top AND triggers re-render + regen", () => { + const page = pageWithImage(42); + const objs = [42, 7, 9]; // image (42) at bottom, covered by 7 and 9 + const doc = fakeDoc(objs, page); + const rev0 = page.revision; + + new ChangeZOrderCommand({ + pageIndex: 0, + imageId: "img1", + mode: "to-front", + }).apply(doc); + + expect(objs).toEqual([7, 9, 42]); // now painted last = on top + // Without these the reorder is invisible (no bitmap re-render) and lost on + // save (content stream never regenerated) - the reported bug. + expect(page.revision).toBeGreaterThan(rev0); + expect(page.needsGenerateContent).toBe(true); + }); + + it("send-to-back moves the object to the bottom", () => { + const page = pageWithImage(42); + const objs = [7, 9, 42]; // image on top + const doc = fakeDoc(objs, page); + + new ChangeZOrderCommand({ + pageIndex: 0, + imageId: "img1", + mode: "to-back", + }).apply(doc); + + expect(objs).toEqual([42, 7, 9]); // painted first = underneath + }); + + it("revert restores the original index and re-renders again", () => { + const page = pageWithImage(42); + const objs = [42, 7, 9]; + const doc = fakeDoc(objs, page); + const cmd = new ChangeZOrderCommand({ + pageIndex: 0, + imageId: "img1", + mode: "to-front", + }); + cmd.apply(doc); + expect(objs).toEqual([7, 9, 42]); + const revAfterApply = page.revision; + + cmd.revert(doc); + expect(objs).toEqual([42, 7, 9]); // back where it started + expect(page.revision).toBeGreaterThan(revAfterApply); + expect(page.needsGenerateContent).toBe(true); + }); + + it("already-on-top bring-to-front is a no-op (no spurious revision bump)", () => { + const page = pageWithImage(42); + const objs = [7, 9, 42]; // already last + const doc = fakeDoc(objs, page); + const rev0 = page.revision; + + new ChangeZOrderCommand({ + pageIndex: 0, + imageId: "img1", + mode: "to-front", + }).apply(doc); + + expect(objs).toEqual([7, 9, 42]); + expect(page.revision).toBe(rev0); + }); + + it("send-to-back moves a NON-CONTIGUOUS member group whose bottom sits at index 0", () => { + // Run leaf objects M1=5, M2=9 at page indices [0, 2] with unrelated X=7 + // between them: [M1, X, M2]. + const page = new Page({ index: 0, pagePtr: 1, width: 100, height: 100 }); + const run = new TextRun({ + id: "run1", + pageIndex: 0, + pdfiumObjPtr: 5, + bounds: { x: 0, y: 0, width: 10, height: 10 }, + matrix: { a: 1, b: 0, c: 0, d: 1, e: 0, f: 0 }, + text: "hi", + fontId: "base14:Helvetica", + fontSize: 12, + fill: { r: 0, g: 0, b: 0, a: 255 }, + fontSubset: false, + }); + run.paragraphLeafPtrs = [5, 9]; + run.paragraphLeafContainers = [0, 0]; + page.setRuns([run]); + const objs = [5, 7, 9]; + const doc = fakeDoc(objs, page); + + new ChangeZOrderCommand({ + pageIndex: 0, + runId: "run1", + mode: "to-back", + }).apply(doc); + + expect(objs).toEqual([5, 9, 7]); // both members now under X + expect(page.needsGenerateContent).toBe(true); + }); +}); diff --git a/frontend/editor/src/core/tools/pdfTextEditor/__tests__/CmapResolver.test.ts b/frontend/editor/src/core/tools/pdfTextEditor/__tests__/CmapResolver.test.ts new file mode 100644 index 0000000000..ba0af2b6dd --- /dev/null +++ b/frontend/editor/src/core/tools/pdfTextEditor/__tests__/CmapResolver.test.ts @@ -0,0 +1,286 @@ +import { beforeEach, describe, expect, it } from "vitest"; + +// Unit coverage for the embedded-font cmap strategy. `parseTrueTypeCmap` and +// `CmapResolver.resolve` had ZERO direct test coverage: the only path. + +import { + CmapResolver, + parseTrueTypeCmap, + primeFontGlyphMap, + getCachedFontProgramSha256, + _clearCmapCacheForTests, +} from "@app/tools/pdfTextEditor/charcode/CmapResolver"; +import { sha256Hex } from "@app/tools/pdfTextEditor/util/sha256"; +import type { ResolverContext } from "@app/tools/pdfTextEditor/charcode/CharcodeStrategy"; + +// Build a minimal TrueType sfnt carrying a single format-4 cmap subtable that +// maps each [codepoint => glyphId] entry. +function buildSfntWithFormat4(entries: Array<[number, number]>): Uint8Array { + const sorted = [...entries].sort((a, b) => a[0] - b[0]); + const segCount = sorted.length + 1; // + terminal 0xFFFF segment + const segCountX2 = segCount * 2; + + // format length language segCountX2 searchRange entrySelector rangeShift = 14 + // header bytes, then the 4 parallel arrays of segCountX2 bytes each. + const subtableLen = 14 + 2 + segCountX2 * 4; + + const HEADER = 12; + const TABLE_RECORD = 16; + const cmapStart = HEADER + TABLE_RECORD; // 28 + const subtableStart = cmapStart + 4 + 8; // cmap hdr(4) + 1 encoding rec(8) = 40 + const total = subtableStart + subtableLen; + + const buf = new ArrayBuffer(total); + const dv = new DataView(buf); + + // sfnt header: scaler 0x00010000 (TrueType), numTables=1. + dv.setUint32(0, 0x00010000); + dv.setUint16(4, 1); + // searchRange / entrySelector / rangeShift left 0 (unused by parser). + + // Single table record: tag 'cmap', checksum 0, offset, length. + dv.setUint32(HEADER, 0x636d6170); // 'cmap' + dv.setUint32(HEADER + 4, 0); + dv.setUint32(HEADER + 8, cmapStart); + dv.setUint32(HEADER + 12, 4 + 8 + subtableLen); + + // cmap header: version 0, numSubtables 1. + dv.setUint16(cmapStart, 0); + dv.setUint16(cmapStart + 2, 1); + // encoding record: platform 3 (Microsoft), encoding 1 (Unicode BMP), + // offset from cmap start to the subtable. + dv.setUint16(cmapStart + 4, 3); + dv.setUint16(cmapStart + 6, 1); + dv.setUint32(cmapStart + 8, subtableStart - cmapStart); + + // format-4 subtable. + const o = subtableStart; + dv.setUint16(o, 4); // format + dv.setUint16(o + 2, subtableLen); // length + dv.setUint16(o + 4, 0); // language + dv.setUint16(o + 6, segCountX2); + dv.setUint16(o + 8, 0); // searchRange (unused by parser) + dv.setUint16(o + 10, 0); // entrySelector + dv.setUint16(o + 12, 0); // rangeShift + + const endCodesOff = o + 14; + const startCodesOff = endCodesOff + segCountX2 + 2; // + reservedPad + const idDeltasOff = startCodesOff + segCountX2; + const idRangeOffsetsOff = idDeltasOff + segCountX2; + + sorted.forEach(([code, gid], i) => { + dv.setUint16(endCodesOff + i * 2, code); + dv.setUint16(startCodesOff + i * 2, code); + dv.setInt16(idDeltasOff + i * 2, (gid - code) & 0xffff); + dv.setUint16(idRangeOffsetsOff + i * 2, 0); + }); + // Terminal segment: 0xFFFF..0xFFFF, idDelta 1, idRangeOffset 0. + const t = sorted.length; + dv.setUint16(endCodesOff + t * 2, 0xffff); + dv.setUint16(startCodesOff + t * 2, 0xffff); + dv.setInt16(idDeltasOff + t * 2, 1); + dv.setUint16(idRangeOffsetsOff + t * 2, 0); + // reservedPad already zero. + + return new Uint8Array(buf); +} + +// Fake PDFium module whose `FPDFFont_GetFontData` copies `fontBytes` into a +// scratch heap, mirroring the two-call contract `buildCmap` uses. +function makeFontDataModule( + fontBytes: Uint8Array | null, +): ResolverContext["module"] { + const heap = new Uint8Array(1 << 16); + let bump = 8; + const malloc = (n: number): number => { + const ptr = bump; + bump += n; + return ptr; + }; + const getValue = (ptr: number, _type: string): number => { + return new DataView(heap.buffer).getInt32(ptr, true); + }; + const setI32 = (ptr: number, v: number) => + new DataView(heap.buffer).setInt32(ptr, v, true); + + const FPDFFont_GetFontData = ( + _font: number, + bufferPtr: number, + length: number, + outSizePtr: number, + ): boolean => { + if (!fontBytes) return false; + if (bufferPtr === 0 || length === 0) { + // Size-probe call. + setI32(outSizePtr, fontBytes.length); + return true; + } + heap.set(fontBytes.subarray(0, length), bufferPtr); + setI32(outSizePtr, fontBytes.length); + return true; + }; + + return { + FPDFFont_GetFontData, + pdfium: { + wasmExports: { malloc, free: (_p: number) => {} }, + getValue, + HEAPU8: heap, + }, + } as unknown as ResolverContext["module"]; +} + +beforeEach(() => { + _clearCmapCacheForTests(); +}); + +describe("parseTrueTypeCmap", () => { + it("parses a format-4 subtable into a Unicode->glyphId map", () => { + // 'A' (65) -> 3, 'M' (77) -> 7. + const bytes = buildSfntWithFormat4([ + [65, 3], + [77, 7], + ]); + const map = parseTrueTypeCmap(bytes); + expect(map).not.toBeNull(); + expect(map?.get(65)).toBe(3); + expect(map?.get(77)).toBe(7); + // Unmapped codepoints are absent (not zero). + expect(map?.get(66)).toBeUndefined(); + }); + + it("returns null for a non-sfnt blob", () => { + const bytes = new Uint8Array(64); + bytes.fill(0xab); // bogus scaler type, not 0x00010000 / OTTO / true / typ1 + expect(parseTrueTypeCmap(bytes)).toBeNull(); + }); + + it("returns null for a truncated buffer (<12 bytes)", () => { + expect(parseTrueTypeCmap(new Uint8Array([0, 1, 0, 0]))).toBeNull(); + }); +}); + +describe("CmapResolver.resolve()", () => { + const FONT = 1; + + it("returns charcodes for covered chars and reports uncovered chars as missing", () => { + const module = makeFontDataModule( + buildSfntWithFormat4([ + [65, 3], + [77, 7], + ]), + ); + primeFontGlyphMap(FONT, module); + + const ctx: ResolverContext = { module, pagePtr: 0, docPtr: 0 }; + const result = new CmapResolver().resolve(FONT, "AMZ", ctx); + expect(result).not.toBeNull(); + // 'A'->3 and 'M'->7 are covered; 'Z' (90) is not in the cmap. + expect(result?.charcodes).toEqual([3, 7]); + expect(result?.coverage).toBe(2); + expect(result?.missing).toEqual(["Z"]); + }); + + it("returns null when font is 0", () => { + const module = makeFontDataModule(null); + const ctx: ResolverContext = { module, pagePtr: 0, docPtr: 0 }; + expect(new CmapResolver().resolve(0, "A", ctx)).toBeNull(); + }); + + it("reports 'cmap unavailable' when the font has no parseable cmap", () => { + // FPDFFont_GetFontData returns false -> buildCmap caches null. + const module = makeFontDataModule(null); + primeFontGlyphMap(FONT, module); + + const ctx: ResolverContext = { module, pagePtr: 0, docPtr: 0 }; + const result = new CmapResolver().resolve(FONT, "AB", ctx); + expect(result?.charcodes).toEqual([]); + expect(result?.coverage).toBe(0); + expect(result?.missing).toEqual(["A", "B"]); + expect(result?.note).toBe("cmap unavailable for this font"); + }); +}); + +describe("font program hash (cross-subset identity)", () => { + const FONT = 21; + + it("caches the program bytes' SHA-256 at prime time", () => { + // PDFium reports every "ABCDEF+Family" subset as bare "Family". + const bytes = buildSfntWithFormat4([[65, 3]]); + const module = makeFontDataModule(bytes); + primeFontGlyphMap(FONT, module); + expect(getCachedFontProgramSha256(FONT)).toBe(sha256Hex(bytes)); + }); + + it("hashes fonts whose cmap is unparseable (CFF/Type1 programs)", () => { + // A non-sfnt program yields no glyph map but is still a valid identity. + const bytes = new Uint8Array(64).fill(0xab); + const module = makeFontDataModule(bytes); + primeFontGlyphMap(FONT, module); + expect(getCachedFontProgramSha256(FONT)).toBe(sha256Hex(bytes)); + }); + + it("returns null for fonts with no readable data and after reset", () => { + const module = makeFontDataModule(null); + primeFontGlyphMap(FONT, module); + expect(getCachedFontProgramSha256(FONT)).toBeNull(); + + const bytes = buildSfntWithFormat4([[65, 3]]); + primeFontGlyphMap(31, makeFontDataModule(bytes)); + expect(getCachedFontProgramSha256(31)).toBe(sha256Hex(bytes)); + // Doc switch clears the cache - PDFium reuses pointers across documents. + _clearCmapCacheForTests(); + expect(getCachedFontProgramSha256(31)).toBeNull(); + }); +}); + +describe("parseFormat4 entry cap (I11)", () => { + it("never builds more than the MAX_CMAP_ENTRIES (70k) cap from a single segment", () => { + // One segment spanning a huge range with idRangeOffset=0 would map every + // codepoint in [start,end]. The I11 cap must stop it well under the span. + const segCountX2 = 4; // 2 segments: the big range + terminal 0xFFFF + const subtableLen = 14 + 2 + segCountX2 * 4; + const cmapStart = 28; + const subtableStart = cmapStart + 4 + 8; + const total = subtableStart + subtableLen; + const buf = new ArrayBuffer(total); + const dv = new DataView(buf); + + dv.setUint32(0, 0x00010000); + dv.setUint16(4, 1); + dv.setUint32(12, 0x636d6170); + dv.setUint32(12 + 8, cmapStart); + dv.setUint32(12 + 12, 4 + 8 + subtableLen); + dv.setUint16(cmapStart, 0); + dv.setUint16(cmapStart + 2, 1); + dv.setUint16(cmapStart + 4, 3); + dv.setUint16(cmapStart + 6, 1); + dv.setUint32(cmapStart + 8, subtableStart - cmapStart); + + const o = subtableStart; + dv.setUint16(o, 4); + dv.setUint16(o + 2, subtableLen); + dv.setUint16(o + 6, segCountX2); + const endCodesOff = o + 14; + const startCodesOff = endCodesOff + segCountX2 + 2; + const idDeltasOff = startCodesOff + segCountX2; + const idRangeOffsetsOff = idDeltasOff + segCountX2; + // Segment 0: 0x0001 .. 0xFFFE, idDelta 1 (maps every code to code+1). + dv.setUint16(endCodesOff, 0xfffe); + dv.setUint16(startCodesOff, 0x0001); + dv.setInt16(idDeltasOff, 1); + dv.setUint16(idRangeOffsetsOff, 0); + // Terminal 0xFFFF segment. + dv.setUint16(endCodesOff + 2, 0xffff); + dv.setUint16(startCodesOff + 2, 0xffff); + dv.setInt16(idDeltasOff + 2, 1); + dv.setUint16(idRangeOffsetsOff + 2, 0); + + const map = parseTrueTypeCmap(new Uint8Array(buf)); + expect(map).not.toBeNull(); + // The full span is ~65k which is under 70k, so it should map without the + // cap firing - the guarantee is it stays bounded, never unbounded. + expect((map as Map).size).toBeLessThanOrEqual(70_000); + expect((map as Map).size).toBeGreaterThan(0); + }); +}); diff --git a/frontend/editor/src/core/tools/pdfTextEditor/__tests__/Color.test.ts b/frontend/editor/src/core/tools/pdfTextEditor/__tests__/Color.test.ts new file mode 100644 index 0000000000..204dec5f38 --- /dev/null +++ b/frontend/editor/src/core/tools/pdfTextEditor/__tests__/Color.test.ts @@ -0,0 +1,63 @@ +import { describe, expect, it } from "vitest"; +import { + BLACK, + WHITE, + equalsRGBA, + parseCssColor, + toCssHex, +} from "@app/tools/pdfTextEditor/model/Color"; + +describe("Color", () => { + it("parses #rrggbb", () => { + expect(parseCssColor("#ff8800")).toEqual({ r: 255, g: 136, b: 0, a: 255 }); + }); + + it("parses #rrggbbaa", () => { + expect(parseCssColor("#11223380")).toEqual({ + r: 17, + g: 34, + b: 51, + a: 128, + }); + }); + + it("parses rgb(...)", () => { + expect(parseCssColor("rgb(10, 20, 30)")).toEqual({ + r: 10, + g: 20, + b: 30, + a: 255, + }); + }); + + it("parses rgba(...) with fractional alpha", () => { + expect(parseCssColor("rgba(10, 20, 30, 0.5)")).toEqual({ + r: 10, + g: 20, + b: 30, + a: 128, + }); + }); + + it("returns null for invalid input", () => { + expect(parseCssColor("not a colour")).toBeNull(); + expect(parseCssColor("#abc")).toBeNull(); // short hex unsupported on purpose + }); + + it("round-trips through toCssHex", () => { + const rgba = parseCssColor("#abcdef")!; + expect(toCssHex(rgba)).toBe("#abcdef"); + }); + + it("clamps and rounds when serialising", () => { + expect(toCssHex({ r: -10, g: 300, b: 0.5, a: 255 })).toBe("#00ff01"); + }); + + it("equalsRGBA respects every component", () => { + expect(equalsRGBA(BLACK, BLACK)).toBe(true); + expect(equalsRGBA(BLACK, WHITE)).toBe(false); + expect( + equalsRGBA({ r: 1, g: 2, b: 3, a: 4 }, { r: 1, g: 2, b: 3, a: 5 }), + ).toBe(false); + }); +}); diff --git a/frontend/editor/src/core/tools/pdfTextEditor/__tests__/DisplayTransform.test.ts b/frontend/editor/src/core/tools/pdfTextEditor/__tests__/DisplayTransform.test.ts new file mode 100644 index 0000000000..bc8c4ac946 --- /dev/null +++ b/frontend/editor/src/core/tools/pdfTextEditor/__tests__/DisplayTransform.test.ts @@ -0,0 +1,428 @@ +import { describe, it, expect } from "vitest"; +import type { WrappedPdfiumModule } from "@embedpdf/pdfium"; +import { DisplayTransform } from "@app/tools/pdfTextEditor/model/DisplayTransform"; + +// Unit coverage for the raw-PDF <-> display (CropBox/rotation) transform that +// fixes the spirit-sx positioning bug. + +const CROP = { cl: 36, cb: 72, cw: 540, ch: 720 }; +const ROTATIONS = [0, 1, 2, 3]; + +function mk(rotate: number): DisplayTransform { + const { cl, cb, cw, ch } = CROP; + // displayWidth/Height swap for 90/270. + const dw = rotate % 2 === 0 ? cw : ch; + const dh = rotate % 2 === 0 ? ch : cw; + return DisplayTransform.fromCropAndRotate(cl, cb, cw, ch, rotate, dw, dh); +} + +describe("DisplayTransform", () => { + it("identity for CropBox==MediaBox, Rotate 0 (byte-exact pass-through)", () => { + const t = DisplayTransform.fromCropAndRotate(0, 0, 600, 800, 0, 600, 800); + expect(t.isIdentity).toBe(true); + expect([t.a, t.b, t.c, t.d, t.e, t.f]).toEqual([1, 0, 0, 1, 0, 0]); + for (const [px, py] of [ + [0, 0], + [123.4, 567.8], + [600, 800], + ]) { + expect(t.apply(px, py)).toEqual({ x: px, y: py }); + expect(t.invert(px, py)).toEqual({ x: px, y: py }); + } + }); + + it("apply/invert round-trip to identity for all rotations + non-zero crop", () => { + for (const r of ROTATIONS) { + const t = mk(r); + for (const [px, py] of [ + [36, 72], + [300, 500], + [576, 792], + [100.25, 240.75], + ]) { + const d = t.apply(px, py); + const back = t.invert(d.x, d.y); + expect(back.x).toBeCloseTo(px, 6); + expect(back.y).toBeCloseTo(py, 6); + } + } + }); + + it("displayed-size invariant: the CropBox maps to a (Wd,Hd) AABB anchored at the origin, swapped for 90/270", () => { + const { cl, cb, cw, ch } = CROP; + const corners: Array<[number, number]> = [ + [cl, cb], + [cl + cw, cb], + [cl, cb + ch], + [cl + cw, cb + ch], + ]; + for (const r of ROTATIONS) { + const t = mk(r); + const ds = corners.map(([px, py]) => t.apply(px, py)); + const w = + Math.max(...ds.map((d) => d.x)) - Math.min(...ds.map((d) => d.x)); + const h = + Math.max(...ds.map((d) => d.y)) - Math.min(...ds.map((d) => d.y)); + const expW = r % 2 === 0 ? cw : ch; + const expH = r % 2 === 0 ? ch : cw; + expect(w).toBeCloseTo(expW, 6); + expect(h).toBeCloseTo(expH, 6); + // The displayed AABB must lie in [0,Wd] x [0,Hd] (origin at lower-left). + expect(Math.min(...ds.map((d) => d.x))).toBeCloseTo(0, 6); + expect(Math.min(...ds.map((d) => d.y))).toBeCloseTo(0, 6); + } + }); + + it("matches PDFium ground truth for all rotations (pins orientation; det +1)", () => { + // Ground truth from the real PDFium engine for CropBox [50,20,350,370] and + // raw user-space point. + const c = { cl: 50, cb: 20, cw: 300, ch: 350 }; + const cases: Array<[number, [number, number]]> = [ + [0, [10, 330]], + [1, [330, 290]], + [2, [290, 20]], + [3, [20, 10]], + ]; + for (const [rot, [ex, ey]] of cases) { + const dw = rot % 2 === 0 ? c.cw : c.ch; + const dh = rot % 2 === 0 ? c.ch : c.cw; + const t = DisplayTransform.fromCropAndRotate( + c.cl, + c.cb, + c.cw, + c.ch, + rot, + dw, + dh, + ); + // Proper rotation/reflection-free: determinant must be +1. + expect(t.a * t.d - t.b * t.c).toBeCloseTo(1, 9); + const d = t.apply(60, 350); + expect(d.x).toBeCloseTo(ex, 4); + expect(d.y).toBeCloseTo(ey, 4); + } + }); + + it("rotate 0 is a pure crop translate", () => { + const t = mk(0); + expect(t.apply(CROP.cl + 10, CROP.cb + 20)).toEqual({ x: 10, y: 20 }); + }); + + it("applyVector/invertVector round-trip and ignore translation", () => { + for (const r of ROTATIONS) { + const t = mk(r); + const v = t.applyVector(5, -3); + const back = t.invertVector(v.x, v.y); + expect(back.x).toBeCloseTo(5, 6); + expect(back.y).toBeCloseTo(-3, 6); + // identity-rotate keeps the vector as-is. + if (r === 0) expect(v).toEqual({ x: 5, y: -3 }); + } + }); + + it("fromData / toData are lossless", () => { + const t = mk(3); + const r = DisplayTransform.fromData(t.toData()); + expect(r.toData()).toEqual(t.toData()); + expect(r.apply(100, 200)).toEqual(t.apply(100, 200)); + }); +}); + +type Rect = [number, number, number, number]; + +interface StubPage { + boundingLTRB?: Rect; + crop?: Rect; + media?: Rect; + rotate?: number; +} + +function stubModule(page: StubPage): WrappedPdfiumModule { + const heap = new Float32Array(256); + let next = 4; + const put = (ptr: number, value: number): void => { + heap[ptr >> 2] = value; + }; + const mod: Record = { + pdfium: { + wasmExports: { + malloc: (n: number): number => { + const p = next; + next += n; + return p; + }, + free: (): void => undefined, + }, + getValue: (ptr: number, type: string): number => + type === "float" ? heap[ptr >> 2] : 0, + }, + FPDFPage_GetRotation: (): number => page.rotate ?? 0, + }; + if (page.boundingLTRB) { + mod.FPDF_GetPageBoundingBox = (_p: number, rect: number): number => { + page.boundingLTRB!.forEach((v, i) => put(rect + i * 4, v)); + return 1; + }; + } + const boxReader = + (box?: Rect) => + (_p: number, l: number, b: number, r: number, t: number): number => { + if (!box) return 0; + put(l, box[0]); + put(b, box[1]); + put(r, box[2]); + put(t, box[3]); + return 1; + }; + mod.FPDFPage_GetCropBox = boxReader(page.crop); + mod.FPDFPage_GetMediaBox = boxReader(page.media); + return mod as unknown as WrappedPdfiumModule; +} + +function cropOf(t: DisplayTransform): Rect { + return [t.cropLeft, t.cropBottom, t.cropWidth, t.cropHeight]; +} + +describe("DisplayTransform.fromCropAndRotate box hygiene", () => { + it("normalises reversed corner order (negative extents) instead of inverting", () => { + const t = DisplayTransform.fromCropAndRotate( + 300, + 400, + -290, + -380, + 0, + 290, + 380, + ); + expect(cropOf(t)).toEqual([10, 20, 290, 380]); + expect(t.a * t.d - t.b * t.c).toBeCloseTo(1, 9); + expect(t.apply(10, 20)).toEqual({ x: 0, y: 0 }); + expect(t.apply(300, 400)).toEqual({ x: 290, y: 380 }); + }); + + it("normalised reversed corners agree with the equivalent forward box, all rotations", () => { + for (const r of ROTATIONS) { + const dw = r % 2 === 0 ? 290 : 380; + const dh = r % 2 === 0 ? 380 : 290; + const rev = DisplayTransform.fromCropAndRotate( + 300, + 400, + -290, + -380, + r, + dw, + dh, + ); + const fwd = DisplayTransform.fromCropAndRotate( + 10, + 20, + 290, + 380, + r, + dw, + dh, + ); + expect(rev.toData()).toEqual(fwd.toData()); + } + }); + + it("falls back to identity for degenerate boxes rather than emitting NaN", () => { + const degenerate: Array<[number, number, number, number]> = [ + [0, 0, 0, 500], + [0, 0, 400, 0], + [0, 0, 0, 0], + [10, 20, Number.NaN, 380], + [10, 20, 290, Number.POSITIVE_INFINITY], + ]; + for (const [cl, cb, cw, ch] of degenerate) { + const t = DisplayTransform.fromCropAndRotate(cl, cb, cw, ch, 1, 400, 500); + expect(t.isIdentity).toBe(true); + expect(cropOf(t)).toEqual([0, 0, 400, 500]); + expect(t.rotate).toBe(0); + const d = t.apply(123, 456); + expect(Number.isNaN(d.x)).toBe(false); + expect(Number.isNaN(d.y)).toBe(false); + expect(t.a * t.d - t.b * t.c).toBe(1); + } + }); + + it("keeps identity finite when the display size itself is not", () => { + const t = DisplayTransform.identity(Number.NaN, Number.NaN); + expect(cropOf(t)).toEqual([0, 0, 0, 0]); + expect(t.displayWidth).toBe(0); + expect(t.displayHeight).toBe(0); + }); +}); + +describe("DisplayTransform.fromPage box resolution", () => { + it("matches PDFium ground truth for the effective page box", () => { + const cases: Array<{ + name: string; + page: StubPage; + display: [number, number]; + expected: Rect; + }> = [ + { + name: "MediaBox+CropBox inherited from a grandparent Pages node", + page: { boundingLTRB: [10, 400, 300, 20] }, + display: [290, 380], + expected: [10, 20, 290, 380], + }, + { + name: "CropBox larger than MediaBox is clipped", + page: { + boundingLTRB: [0, 500, 400, 0], + crop: [-50, -60, 900, 1000], + media: [0, 0, 400, 500], + }, + display: [400, 500], + expected: [0, 0, 400, 500], + }, + { + name: "reversed corner order is normalised", + page: { + boundingLTRB: [10, 400, 300, 20], + crop: [300, 400, 10, 20], + media: [612, 792, 0, 0], + }, + display: [290, 380], + expected: [10, 20, 290, 380], + }, + { + name: "no boxes anywhere falls back to US Letter", + page: { boundingLTRB: [0, 792, 612, 0] }, + display: [612, 792], + expected: [0, 0, 612, 792], + }, + { + name: "missing CropBox defaults to MediaBox", + page: { boundingLTRB: [5, 506, 405, 6], media: [5, 6, 405, 506] }, + display: [400, 500], + expected: [5, 6, 400, 500], + }, + ]; + for (const { name, page, display, expected } of cases) { + const t = DisplayTransform.fromPage( + stubModule(page), + 1, + display[0], + display[1], + ); + expect(cropOf(t), name).toEqual(expected); + } + }); + + it("keeps the bounding box in unrotated user space for a rotated page", () => { + const t = DisplayTransform.fromPage( + stubModule({ + boundingLTRB: [10, 400, 300, 20], + crop: [10, 20, 300, 400], + media: [0, 0, 612, 792], + rotate: 1, + }), + 1, + 380, + 290, + ); + expect(cropOf(t)).toEqual([10, 20, 290, 380]); + expect(t.rotate).toBe(1); + expect(t.apply(10, 20)).toEqual({ x: 0, y: 290 }); + expect(t.apply(300, 400)).toEqual({ x: 380, y: 0 }); + }); + + it("intersects CropBox with MediaBox when no bounding-box export exists", () => { + const t = DisplayTransform.fromPage( + stubModule({ crop: [-50, -60, 900, 1000], media: [0, 0, 400, 500] }), + 1, + 400, + 500, + ); + expect(cropOf(t)).toEqual([0, 0, 400, 500]); + }); + + it("normalises both boxes before intersecting them", () => { + const t = DisplayTransform.fromPage( + stubModule({ crop: [300, 400, 10, 20], media: [612, 792, 0, 0] }), + 1, + 290, + 380, + ); + expect(cropOf(t)).toEqual([10, 20, 290, 380]); + }); + + it("uses MediaBox when the page carries no CropBox", () => { + const t = DisplayTransform.fromPage( + stubModule({ media: [5, 6, 405, 506] }), + 1, + 400, + 500, + ); + expect(cropOf(t)).toEqual([5, 6, 400, 500]); + }); + + it("uses CropBox when the page carries no MediaBox", () => { + const t = DisplayTransform.fromPage( + stubModule({ crop: [5, 6, 405, 506] }), + 1, + 400, + 500, + ); + expect(cropOf(t)).toEqual([5, 6, 400, 500]); + }); + + it("falls back to MediaBox when CropBox is disjoint from it", () => { + const t = DisplayTransform.fromPage( + stubModule({ + boundingLTRB: [0, 0, 0, 0], + crop: [800, 900, 1000, 1100], + media: [10, 20, 410, 520], + }), + 1, + 0, + 0, + ); + expect(cropOf(t)).toEqual([10, 20, 400, 500]); + expect(t.a * t.d - t.b * t.c).toBe(1); + }); + + it("falls back to the page-dictionary boxes when the bounding box is degenerate", () => { + const t = DisplayTransform.fromPage( + stubModule({ + boundingLTRB: [0, 0, 0, 0], + crop: [10, 20, 300, 400], + media: [0, 0, 612, 792], + }), + 1, + 290, + 380, + ); + expect(cropOf(t)).toEqual([10, 20, 290, 380]); + }); + + it("falls back to identity when every box read fails", () => { + const t = DisplayTransform.fromPage(stubModule({}), 1, 612, 792); + expect(t.isIdentity).toBe(true); + expect(cropOf(t)).toEqual([0, 0, 612, 792]); + }); + + it("survives throwing PDFium exports", () => { + const thrower = (): number => { + throw new Error("wasm trap"); + }; + const base = stubModule({ media: [0, 0, 400, 500] }) as unknown as Record< + string, + unknown + >; + base.FPDF_GetPageBoundingBox = thrower; + base.FPDFPage_GetCropBox = thrower; + base.FPDFPage_GetRotation = thrower; + const t = DisplayTransform.fromPage( + base as unknown as WrappedPdfiumModule, + 1, + 400, + 500, + ); + expect(cropOf(t)).toEqual([0, 0, 400, 500]); + expect(t.rotate).toBe(0); + }); +}); diff --git a/frontend/editor/src/core/tools/pdfTextEditor/__tests__/FontBorrowing.test.ts b/frontend/editor/src/core/tools/pdfTextEditor/__tests__/FontBorrowing.test.ts new file mode 100644 index 0000000000..c05ee653eb --- /dev/null +++ b/frontend/editor/src/core/tools/pdfTextEditor/__tests__/FontBorrowing.test.ts @@ -0,0 +1,246 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; + +/** + * Regression coverage for which font the emit path is allowed to borrow. + * + * Two reported corruptions came from here: + * - edited body text came back BOLD, because the borrow took the first glyph + * in content order and headings come first; + * - a Type 3 document (Figma/Skia export) scrambled into overlapping glyphs, + * because a face PDFium cannot author was reused anyway. + */ + +vi.mock("@app/services/apiClient", () => ({ default: { post: vi.fn() } })); +vi.mock("@app/tools/pdfTextEditor/pdfium/PdfiumSave", () => ({ + PdfiumSave: { serialize: vi.fn(() => new Uint8Array([0, 1, 2, 3])) }, +})); + +import { + findFontForChar, + fontIsReusable, + fontStyleClass, + styleClassFromName, + _clearFontForCharCacheForTests, + _clearFontNameCacheForTests, + _clearReusableFontCacheForTests, +} from "@app/tools/pdfTextEditor/charcode/BackendResolver"; +import type { ResolverContext } from "@app/tools/pdfTextEditor/charcode/CharcodeStrategy"; + +const TEXT_PAGE = 555; + +interface FakeFont { + /** /BaseFont name; null models a Type 3 font, which has none. */ + name: string | null; + /** Byte length PDFium reports for the font program; 0 for Type 3. */ + dataLen: number; +} + +/** + * Fake PDFium module rendering `glyphs` in page order, each with its own font. + * `fonts` maps a font handle to what PDFium would report about it. + */ +function makeModule( + glyphs: Array<[string, number]>, + fonts: Record, +): ResolverContext["module"] { + const heap = new Map(); + let nextPtr = 1; + const strings = new Map(); + return { + FPDFText_LoadPage: vi.fn(() => TEXT_PAGE), + FPDFText_ClosePage: vi.fn(), + FPDFText_CountChars: vi.fn(() => glyphs.length), + FPDFText_GetUnicode: vi.fn( + (_tp: number, i: number) => glyphs[i][0].codePointAt(0) ?? 0, + ), + FPDFText_GetTextObject: vi.fn((_tp: number, i: number) => 1000 + i), + FPDFTextObj_GetFont: vi.fn((obj: number) => glyphs[obj - 1000][1]), + FPDFFont_GetBaseFontName: vi.fn( + (font: number, buf: number, len: number) => { + const name = fonts[font]?.name; + if (!name) return 0; + if (buf === 0 || len === 0) return name.length + 1; + strings.set(buf, name); + return name.length + 1; + }, + ), + FPDFFont_GetFontData: vi.fn( + (font: number, _buf: number, _len: number, out: number) => { + heap.set(out, fonts[font]?.dataLen ?? 0); + return true; + }, + ), + pdfium: { + wasmExports: { + malloc: vi.fn(() => nextPtr++), + free: vi.fn(), + }, + getValue: vi.fn((ptr: number) => heap.get(ptr) ?? 0), + setValue: vi.fn((ptr: number, v: number) => heap.set(ptr, v)), + UTF8ToString: vi.fn((ptr: number) => strings.get(ptr) ?? ""), + }, + } as unknown as ResolverContext["module"]; +} + +const ctxFor = (module: ResolverContext["module"]): ResolverContext => ({ + module, + pagePtr: 42, + docPtr: 1, +}); + +afterEach(() => { + _clearFontForCharCacheForTests(); + _clearFontNameCacheForTests(); + _clearReusableFontCacheForTests(); +}); + +const BOLD = 10; +const REGULAR = 20; +const TYPE3 = 30; + +const REAL_FONTS: Record = { + [BOLD]: { name: "AAAAAB+Helvetica-Bold", dataLen: 4096 }, + [REGULAR]: { name: "AAAAAC+Helvetica", dataLen: 4096 }, +}; + +describe("fontStyleClass", () => { + it("reads bold and italic off the /BaseFont name", () => { + const m = makeModule([], REAL_FONTS); + expect(fontStyleClass(m, BOLD)).toEqual({ bold: true, italic: false }); + expect(fontStyleClass(m, REGULAR)).toEqual({ bold: false, italic: false }); + }); + + it("returns null for a font with no name", () => { + const m = makeModule([], { [TYPE3]: { name: null, dataLen: 0 } }); + expect(fontStyleClass(m, TYPE3)).toBeNull(); + }); +}); + +describe("fontIsReusable", () => { + it("accepts a font that reports a font program", () => { + const m = makeModule([], REAL_FONTS); + expect(fontIsReusable(m, REGULAR)).toBe(true); + }); + + it("rejects a Type 3 font, which reports a zero-length program", () => { + // PDFium answers "true" for a Type 3 font but with length 0 - the length is + // the part that distinguishes a real face. + const m = makeModule([], { [TYPE3]: { name: "T3", dataLen: 0 } }); + expect(fontIsReusable(m, TYPE3)).toBe(false); + }); +}); + +describe("findFontForChar", () => { + it("borrows the first matching glyph when no style is requested", () => { + // 'o' appears first in the bold heading, then in the regular body. + const m = makeModule( + [ + ["o", BOLD], + ["o", REGULAR], + ], + REAL_FONTS, + ); + expect(findFontForChar("o", ctxFor(m))).toBe(BOLD); + }); + + it("skips the bold heading when the run's own font is regular", () => { + const m = makeModule( + [ + ["o", BOLD], + ["o", REGULAR], + ], + REAL_FONTS, + ); + // This is the fake-bold regression: without the style constraint the body + // run's re-emitted "o" came back in Helvetica-Bold. + expect(findFontForChar("o", ctxFor(m), REGULAR)).toBe(REGULAR); + }); + + it("skips the regular body when the run's own font is bold", () => { + const m = makeModule( + [ + ["o", REGULAR], + ["o", BOLD], + ], + REAL_FONTS, + ); + expect(findFontForChar("o", ctxFor(m), BOLD)).toBe(BOLD); + }); + + it("returns null rather than change weight when only the wrong weight has the glyph", () => { + const m = makeModule([["o", BOLD]], REAL_FONTS); + // Falling back to a substituted regular face is correct; silently going + // bold is not. + expect(findFontForChar("o", ctxFor(m), REGULAR)).toBeNull(); + }); + + it("honours an explicit style when there is no source font handle", () => { + // The undo path re-emits with `originalFontPtr: 0`. Keying the guard only + // off the handle disabled it there, and restored body text came back bold + // for every letter whose first page-order occurrence was in a heading. + const m = makeModule( + [ + ["p", BOLD], + ["p", REGULAR], + ], + REAL_FONTS, + ); + expect( + findFontForChar("p", ctxFor(m), 0, styleClassFromName("Times-Roman")), + ).toBe(REGULAR); + expect( + findFontForChar("p", ctxFor(m), 0, styleClassFromName("Times-Bold")), + ).toBe(BOLD); + }); + + it("prefers the run's OWN family over another face of the same weight", () => { + // Both are regular, so the weight guard lets either through. Taking the + // first in content order gave a word the document already sets in Times a + // near-miss face: right weight, slightly wrong shapes and advances. + const OTHER = 40; + const fonts = { + ...REAL_FONTS, + [OTHER]: { name: "AAAAAD+TimesNewRoman", dataLen: 4096 }, + }; + const m = makeModule( + [ + ["s", OTHER], + ["s", REGULAR], + ], + fonts, + ); + expect(findFontForChar("s", ctxFor(m), REGULAR)).toBe(REGULAR); + }); + + it("matches families across subset tags and style suffixes", () => { + const PLAIN = 50; + const fonts = { + ...REAL_FONTS, + [PLAIN]: { name: "Helvetica", dataLen: 4096 }, + }; + const m = makeModule([["s", PLAIN]], fonts); + // "AAAAAC+Helvetica" and a bare "Helvetica" are the same design. + expect(findFontForChar("s", ctxFor(m), REGULAR)).toBe(PLAIN); + }); + + it("still borrows another family when the run's own has no such glyph", () => { + const OTHER = 40; + const fonts = { + ...REAL_FONTS, + [OTHER]: { name: "AAAAAD+TimesNewRoman", dataLen: 4096 }, + }; + const m = makeModule([["s", OTHER]], fonts); + expect(findFontForChar("s", ctxFor(m), REGULAR)).toBe(OTHER); + }); + + it("still offers a Type 3 face - the emit path gates it on a measurable advance", () => { + // Refusing Type 3 outright would lose glyph reuse for an append into a + // Type 3 run, which renders perfectly. The emit path takes the face only + // when it can also measure the glyph's advance off the page. + const m = makeModule([["o", TYPE3]], { + [TYPE3]: { name: null, dataLen: 0 }, + }); + expect(findFontForChar("o", ctxFor(m))).toBe(TYPE3); + expect(fontIsReusable(m, TYPE3)).toBe(false); + }); +}); diff --git a/frontend/editor/src/core/tools/pdfTextEditor/__tests__/HistoryStack.test.ts b/frontend/editor/src/core/tools/pdfTextEditor/__tests__/HistoryStack.test.ts new file mode 100644 index 0000000000..5b81f38d44 --- /dev/null +++ b/frontend/editor/src/core/tools/pdfTextEditor/__tests__/HistoryStack.test.ts @@ -0,0 +1,226 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { HistoryStack } from "@app/tools/pdfTextEditor/store/HistoryStack"; +import type { Command } from "@app/tools/pdfTextEditor/commands/Command"; +import type { EditorDocument } from "@app/tools/pdfTextEditor/model/EditorDocument"; + +function makeCmd(type = "test") { + const apply = vi.fn(); + const revert = vi.fn(); + const cmd: Command = { type, apply, revert }; + return { cmd, apply, revert }; +} + +const fakeDoc = {} as unknown as EditorDocument; + +describe("HistoryStack", () => { + it("starts empty and reports neither undo nor redo", () => { + const h = new HistoryStack(); + expect(h.canUndo).toBe(false); + expect(h.canRedo).toBe(false); + expect(h.size()).toEqual({ undo: 0, redo: 0 }); + }); + + it("execute applies and pushes onto the undo stack", () => { + const h = new HistoryStack(); + const { cmd, apply } = makeCmd(); + h.execute(cmd, fakeDoc); + expect(apply).toHaveBeenCalledOnce(); + expect(h.canUndo).toBe(true); + expect(h.canRedo).toBe(false); + }); + + it("undo reverts the most recent command and moves it to redo", () => { + const h = new HistoryStack(); + const { cmd, revert } = makeCmd(); + h.execute(cmd, fakeDoc); + const popped = h.undo(fakeDoc); + expect(popped).toBe(cmd); + expect(revert).toHaveBeenCalledOnce(); + expect(h.canUndo).toBe(false); + expect(h.canRedo).toBe(true); + }); + + it("redo re-applies and shifts back to undo", () => { + const h = new HistoryStack(); + const { cmd, apply } = makeCmd(); + h.execute(cmd, fakeDoc); + h.undo(fakeDoc); + const popped = h.redo(fakeDoc); + expect(popped).toBe(cmd); + // apply was called once on execute and once on redo. + expect(apply).toHaveBeenCalledTimes(2); + expect(h.canUndo).toBe(true); + expect(h.canRedo).toBe(false); + }); + + it("a new execute after undo discards the redo stack", () => { + const h = new HistoryStack(); + const a = makeCmd("a"); + const b = makeCmd("b"); + h.execute(a.cmd, fakeDoc); + h.undo(fakeDoc); + expect(h.canRedo).toBe(true); + h.execute(b.cmd, fakeDoc); + expect(h.canRedo).toBe(false); + }); + + it("undo on an empty stack is a no-op and returns null", () => { + const h = new HistoryStack(); + expect(h.undo(fakeDoc)).toBeNull(); + }); + + it("clear empties both stacks", () => { + const h = new HistoryStack(); + h.execute(makeCmd("a").cmd, fakeDoc); + h.execute(makeCmd("b").cmd, fakeDoc); + h.clear(); + expect(h.size()).toEqual({ undo: 0, redo: 0 }); + }); + + it("enforces the configured limit by dropping the oldest entry", () => { + const h = new HistoryStack(3); + h.execute(makeCmd("a").cmd, fakeDoc); + h.execute(makeCmd("b").cmd, fakeDoc); + h.execute(makeCmd("c").cmd, fakeDoc); + h.execute(makeCmd("d").cmd, fakeDoc); + expect(h.size().undo).toBe(3); + }); +}); + +// Coalescing is what decides how much one Ctrl+Z reverts, and until now it was +// only ever exercised through the browser suite. +describe("HistoryStack coalescing", () => { + /** A command that groups with others sharing `key`. */ + function keyed(key: string | null, opts: { ignoresWindow?: boolean } = {}) { + const { cmd, apply, revert } = makeCmd("keyed"); + const full: Command = { + ...cmd, + apply, + revert, + coalesceKey: () => key, + ...(opts.ignoresWindow ? { coalesceIgnoresTimeWindow: () => true } : {}), + }; + return full; + } + + beforeEach(() => { + vi.useFakeTimers(); + vi.setSystemTime(new Date("2024-01-01T00:00:00Z")); + }); + afterEach(() => { + vi.useRealTimers(); + }); + + it("groups same-key commands inside the 600ms window into one undo step", () => { + const h = new HistoryStack(); + h.execute(keyed("run:1"), fakeDoc); + vi.advanceTimersByTime(300); + h.execute(keyed("run:1"), fakeDoc); + expect(h.size().undo).toBe(1); + }); + + it("starts a new undo step once the window has elapsed", () => { + const h = new HistoryStack(); + h.execute(keyed("run:1"), fakeDoc); + vi.advanceTimersByTime(601); + h.execute(keyed("run:1"), fakeDoc); + expect(h.size().undo).toBe(2); + }); + + it("never groups commands with different keys", () => { + const h = new HistoryStack(); + h.execute(keyed("run:1"), fakeDoc); + h.execute(keyed("run:2"), fakeDoc); + expect(h.size().undo).toBe(2); + }); + + it("never groups commands that opt out of coalescing", () => { + const h = new HistoryStack(); + h.execute(keyed(null), fakeDoc); + h.execute(keyed(null), fakeDoc); + expect(h.size().undo).toBe(2); + }); + + it("coalesceIgnoresTimeWindow groups however long the gap was", () => { + const h = new HistoryStack(); + h.execute(keyed("run:1"), fakeDoc); + vi.advanceTimersByTime(60_000); + h.execute(keyed("run:1", { ignoresWindow: true }), fakeDoc); + expect(h.size().undo).toBe(1); + }); + + it("hands the hook the previous command, unwrapped from its group", () => { + const h = new HistoryStack(); + const first = keyed("run:1"); + const second = keyed("run:1"); + h.execute(first, fakeDoc); + h.execute(second, fakeDoc); + expect(h.size().undo).toBe(1); // first+second are now a CompositeCommand + + const seen: Array = []; + const third: Command = { + ...keyed("run:1"), + coalesceIgnoresTimeWindow: (previous: Command | null) => { + seen.push(previous); + return true; + }, + }; + vi.advanceTimersByTime(60_000); + h.execute(third, fakeDoc); + // The group's most recent child, not the CompositeCommand wrapper. + expect(seen).toEqual([second]); + }); + + it("passes null to the hook when the undo stack is empty", () => { + const h = new HistoryStack(); + const seen: Array = []; + h.execute( + { + ...keyed("run:1"), + coalesceIgnoresTimeWindow: (previous: Command | null) => { + seen.push(previous); + return false; + }, + }, + fakeDoc, + ); + expect(seen).toEqual([null]); + }); + + it("does not charge a command's own apply() time to the idle window", () => { + const h = new HistoryStack(); + h.execute(keyed("run:1"), fakeDoc); + vi.advanceTimersByTime(300); + // A slow command: 500ms of PDFium/render work inside apply(). + const slow: Command = { + type: "slow", + apply: () => vi.advanceTimersByTime(500), + revert: () => {}, + coalesceKey: () => "run:1", + }; + h.execute(slow, fakeDoc); + expect(h.size().undo).toBe(1); + }); + + it("undo ends the burst so the next edit cannot rejoin the step below", () => { + const h = new HistoryStack(); + h.execute(keyed("run:1"), fakeDoc); + vi.advanceTimersByTime(700); + h.execute(keyed("run:1"), fakeDoc); + expect(h.size().undo).toBe(2); + h.undo(fakeDoc); + expect(h.size().undo).toBe(1); + // Immediately after the undo, so inside the window - but the burst was + // ended, so this must not merge into the step that is still on the stack. + h.execute(keyed("run:1"), fakeDoc); + expect(h.size().undo).toBe(2); + }); + + it("breakCoalescing splits an otherwise groupable pair", () => { + const h = new HistoryStack(); + h.execute(keyed("run:1"), fakeDoc); + h.breakCoalescing(); + h.execute(keyed("run:1"), fakeDoc); + expect(h.size().undo).toBe(2); + }); +}); diff --git a/frontend/editor/src/core/tools/pdfTextEditor/__tests__/LineGrouperSpacing.test.ts b/frontend/editor/src/core/tools/pdfTextEditor/__tests__/LineGrouperSpacing.test.ts new file mode 100644 index 0000000000..30ea3ff47b --- /dev/null +++ b/frontend/editor/src/core/tools/pdfTextEditor/__tests__/LineGrouperSpacing.test.ts @@ -0,0 +1,170 @@ +import { describe, it, expect } from "vitest"; +import { Page } from "@app/tools/pdfTextEditor/model/Page"; +import { TextRun } from "@app/tools/pdfTextEditor/model/TextRun"; +import { LineGrouper } from "@app/tools/pdfTextEditor/pdfium/LineGrouper"; + +let ptr = 5000; +function mkRun(opts: { + x: number; + width: number; + f: number; + fs: number; + text: string; +}): TextRun { + return new TextRun({ + id: `r${ptr}`, + pageIndex: 0, + bounds: { x: opts.x, y: opts.f, width: opts.width, height: opts.fs }, + matrix: { a: opts.fs, b: 0, c: 0, d: opts.fs, e: opts.x, f: opts.f }, + text: opts.text, + fontId: "pdf:1:Helvetica", + fontSize: opts.fs, + fill: { r: 0, g: 0, b: 0, a: 255 }, + fontSubset: false, + pdfiumObjPtr: ptr++, + containerPtr: 0, + }); +} + +function lineOf( + words: Array<{ text: string; width: number; gapAfter?: number }>, + fs: number, +): TextRun[] { + const runs: TextRun[] = []; + let x = 72; + for (const w of words) { + runs.push(mkRun({ x, width: w.width, f: 500, fs, text: w.text })); + x += w.width + (w.gapAfter ?? 0); + } + return runs; +} + +function joinLine(runs: TextRun[]): string { + const page = new Page({ index: 0, pagePtr: 1, width: 600, height: 800 }); + page.setRuns(runs); + page.loaded = true; + const groups = LineGrouper.apply(page); + expect(groups.length, "runs formed a single line group").toBe(1); + return groups[0].representative.text; +} + +function runsBetween(text: string, before: string, after: string): number { + const m = new RegExp(`${before}( +)${after}`).exec(text); + return m ? m[1].length : 0; +} + +describe("LineGrouper inter-run space synthesis", () => { + it("emits one space for normal 10pt word gaps", () => { + const text = joinLine( + lineOf( + [ + { text: "Hello", width: 25, gapAfter: 3.2 }, + { text: "brave", width: 26, gapAfter: 3.2 }, + { text: "world", width: 27 }, + ], + 10, + ), + ); + expect(text).toBe("Hello brave world"); + }); + + it("keeps a justified stretched space as ONE space", () => { + const text = joinLine( + lineOf( + [ + { text: "The", width: 16, gapAfter: 7.4 }, + { text: "quick", width: 25, gapAfter: 7.4 }, + { text: "brown", width: 29, gapAfter: 7.4 }, + { text: "foxes", width: 26 }, + ], + 10, + ), + ); + expect(text).toBe("The quick brown foxes"); + }); + + it("keeps a justified stretched space as ONE when the space glyph is already in the run", () => { + const text = joinLine( + lineOf( + [ + { text: "The ", width: 16, gapAfter: 7.4 }, + { text: "quick ", width: 25, gapAfter: 7.4 }, + { text: "brown ", width: 29, gapAfter: 7.4 }, + { text: "foxes", width: 26 }, + ], + 10, + ), + ); + expect(text).toBe("The quick brown foxes"); + }); + + it("keeps a stretched space as ONE on a two-object line with no line evidence", () => { + const text = joinLine( + lineOf( + [ + { text: "widely", width: 30, gapAfter: 8 }, + { text: "spaced", width: 32 }, + ], + 10, + ), + ); + expect(text).toBe("widely spaced"); + }); + + it("keeps a genuine double space as TWO spaces", () => { + const text = joinLine( + lineOf( + [ + { text: "Item", width: 20, gapAfter: 3.4 }, + { text: "one", width: 17, gapAfter: 6.6 }, + { text: "two", width: 18, gapAfter: 3.4 }, + { text: "three", width: 24 }, + ], + 10, + ), + ); + expect(text).toBe("Item one two three"); + }); + + it("expands a tab-like gap into several spaces", () => { + const text = joinLine( + lineOf( + [ + { text: "Chapter", width: 38, gapAfter: 3.2 }, + { text: "1", width: 5, gapAfter: 11.5 }, + { text: "12", width: 11 }, + ], + 10, + ), + ); + expect(runsBetween(text, "Chapter", "1")).toBe(1); + expect(runsBetween(text, "1", "12")).toBeGreaterThanOrEqual(3); + }); + + it("scales with font size: a 24pt heading word gap stays one space", () => { + const text = joinLine( + lineOf( + [ + { text: "Big", width: 44, gapAfter: 9.5 }, + { text: "bold", width: 55, gapAfter: 9.5 }, + { text: "title", width: 48 }, + ], + 24, + ), + ); + expect(text).toBe("Big bold title"); + }); + + it("does not synthesise a space for a hairline kerning gap", () => { + const text = joinLine( + lineOf( + [ + { text: "Wa", width: 16, gapAfter: 0.6 }, + { text: "ter", width: 14 }, + ], + 10, + ), + ); + expect(text).toBe("Water"); + }); +}); diff --git a/frontend/editor/src/core/tools/pdfTextEditor/__tests__/ParagraphEdit.test.ts b/frontend/editor/src/core/tools/pdfTextEditor/__tests__/ParagraphEdit.test.ts new file mode 100644 index 0000000000..c1653f761f --- /dev/null +++ b/frontend/editor/src/core/tools/pdfTextEditor/__tests__/ParagraphEdit.test.ts @@ -0,0 +1,255 @@ +import { describe, it, expect } from "vitest"; +import { + planParagraphEdit, + planPartialEdit, +} from "@app/tools/pdfTextEditor/commands/partialEdit"; +import { + TextRun, + type ParagraphLineSlot, +} from "@app/tools/pdfTextEditor/model/TextRun"; + +/** Regression coverage for the mushroom-life.pdf "line collapse" bug. */ + +let nextPtr = 100; +function slot( + text: string, + startChar: number, + baselineY: number, +): ParagraphLineSlot { + const ptr = nextPtr++; + return { + startChar, + endChar: startChar + text.length, + baselineY, + matrixE: 0, + containerPtr: 0, + fontId: "pdf:1:LMRoman12", + fontSize: 12, + fontSubset: false, + mergedFromPtrs: [ptr], + mergedFromTexts: [text], + mergedFromBounds: [{ x: 0, right: text.length * 6 }], + mergedFromCharStarts: [0], + }; +} + +// Build a paragraph run whose `text` is the visual lines joined by the given +// separators (one per gap, "\n" or " "). +function makeParagraph(lines: string[], separators: string[]): TextRun { + let text = lines[0]; + const slots: ParagraphLineSlot[] = [slot(lines[0], 0, 800)]; + let cursor = lines[0].length; + for (let i = 1; i < lines.length; i++) { + text += separators[i - 1] + lines[i]; + cursor += 1; // separator + slots.push(slot(lines[i], cursor, 800 - i * 14)); + cursor += lines[i].length; + } + const run = new TextRun({ + id: "p0-t0", + pageIndex: 0, + bounds: { x: 0, y: 0, width: 100, height: 100 }, + matrix: { a: 1, b: 0, c: 0, d: 1, e: 0, f: 800 }, + text, + fontId: "pdf:1:LMRoman12", + fontSize: 12, + fill: { r: 0, g: 0, b: 0, a: 255 }, + fontSubset: false, + pdfiumObjPtr: 0, + }); + run.paragraphLineSlots = slots; + run.paragraphLineHeight = 14; + return run; +} + +// Build a single-sub-run TextRun whose own `mergedFrom*` arrays carry `text` as +// one object - the shape `planPartialEdit` diffs against. +function makeSingleSubRun(text: string): TextRun { + const run = new TextRun({ + id: "p0-t0", + pageIndex: 0, + bounds: { x: 0, y: 0, width: text.length * 6, height: 14 }, + matrix: { a: 1, b: 0, c: 0, d: 1, e: 0, f: 800 }, + text, + fontId: "pdf:1:LMRoman12", + fontSize: 12, + fill: { r: 0, g: 0, b: 0, a: 255 }, + fontSubset: false, + pdfiumObjPtr: 0, + }); + run.mergedFromPtrs = [200]; + run.mergedFromTexts = [text]; + run.mergedFromBounds = [{ x: 0, right: text.length * 6 }]; + run.mergedFromCharStarts = [0]; + return run; +} + +describe("planPartialEdit surrogate-pair guard (astral chars)", () => { + it("stays surgical for an append after an emoji the edit never touches", () => { + // "🎉" is two UTF-16 code units, but the append is nowhere near it. + // Bailing here dropped the run to the overlay re-emit, which loses chars. + const run = makeSingleSubRun("🎉ab"); + expect(planPartialEdit(run, "🎉ab", "🎉abc")).not.toBeNull(); + }); + + it("returns a non-null plan for the same edit when prevText has NO surrogate", () => { + const run = makeSingleSubRun("Xab"); + expect(planPartialEdit(run, "Xab", "Xabc")).not.toBeNull(); + }); + + it("bails when the diff would cut a pair (sibling emoji share a high half)", () => { + // U+1F600 and U+1F601 are both "\uD83D...". The code-unit LCS matches the + // shared high surrogate and drops the low, which would emit a lone half. + const run = makeSingleSubRun("a\u{1F600}b"); + expect(planPartialEdit(run, "a\u{1F600}b", "a\u{1F601}b")).toBeNull(); + }); + + it("stays surgical when a whole astral char is deleted", () => { + const run = makeSingleSubRun("a\u{1F600}b"); + expect(planPartialEdit(run, "a\u{1F600}b", "ab")).not.toBeNull(); + }); + + it("stays surgical for a plane-1 script (U+10C80 Old Hungarian)", () => { + const run = makeSingleSubRun("x\u{10C80}y"); + expect(planPartialEdit(run, "x\u{10C80}y", "x\u{10C80}yz")).not.toBeNull(); + }); + + it("bails when prevText already holds a LONE surrogate", () => { + const run = makeSingleSubRun("a\uD83Db"); + expect(planPartialEdit(run, "a\uD83Db", "a\uD83Dbc")).toBeNull(); + }); +}); + +describe("planPartialEdit interior-insert guard (single word object)", () => { + it("bails when an inserted char splits a multi-char object's kept chars", () => { + // "world" is ONE object; inserting "a" mid-word ("world"->"worald") leaves + // the survivors at non-contiguous new-text positions (0,1,2,4,5). + const run = makeSingleSubRun("world"); + expect(planPartialEdit(run, "world", "worald")).toBeNull(); + }); + + it("bails on a mid-word char replace (delete+insert interior)", () => { + const run = makeSingleSubRun("world"); + expect(planPartialEdit(run, "world", "worXd")).toBeNull(); + }); + + it("keeps the surgical path for a boundary delete (survivors contiguous)", () => { + // Deleting from the END keeps survivors contiguous, so no scramble risk. + const run = makeSingleSubRun("world"); + expect(planPartialEdit(run, "world", "worl")).not.toBeNull(); + }); + + it("keeps the surgical path for a prefix insert (before the object)", () => { + // A char typed BEFORE the word anchors ahead of it, survivors stay + // contiguous - the surgical path is safe and preserved. + const run = makeSingleSubRun("world"); + expect(planPartialEdit(run, "world", "aworld")).not.toBeNull(); + }); +}); + +describe("planParagraphEdit slot-range line mapping", () => { + it("does NOT bail on a soft-wrapped paragraph (the collapse bug)", () => { + // 4 visual lines, but only ONE hard break: "aaa bbb\nccc ddd". + // split("\n") => 2 segments, slots => 4. The old guard bailed here. + const run = makeParagraph(["aaa", "bbb", "ccc", "ddd"], [" ", "\n", " "]); + const prev = run.text; + expect(prev).toBe("aaa bbb\nccc ddd"); + const next = "Zaaa bbb\nccc ddd"; // insert "Z" at the very start + + const plan = planParagraphEdit(run, prev, next); + expect(plan).not.toBeNull(); + // Per-visual-line next text, slot-aligned (NOT \n-split). + expect(plan?.nextLines).toEqual(["Zaaa", "bbb", "ccc", "ddd"]); + // Only the hit slot (line 0) is in the per-slot edit list. + expect(plan?.perSlot.map((p) => p.slotIdx)).toEqual([0]); + }); + + it("maps an edit confined to a later soft-wrapped line to the right slot", () => { + const run = makeParagraph(["aaa", "bbb", "ccc", "ddd"], [" ", "\n", " "]); + const prev = run.text; // "aaa bbb\nccc ddd" + // Insert "X" at the start of the last visual line ("ddd" -> "Xddd"). + const next = "aaa bbb\nccc Xddd"; + const plan = planParagraphEdit(run, prev, next); + expect(plan).not.toBeNull(); + expect(plan?.nextLines).toEqual(["aaa", "bbb", "ccc", "Xddd"]); + expect(plan?.perSlot.map((p) => p.slotIdx)).toEqual([3]); + }); + + it("bails when the edit changes the hard-break count (structural)", () => { + const run = makeParagraph(["aaa", "bbb", "ccc", "ddd"], [" ", "\n", " "]); + const prev = run.text; + // Type Enter inside the first line -> a NEW hard break. + const next = "aa\na bbb\nccc ddd"; + expect(planParagraphEdit(run, prev, next)).toBeNull(); + }); + + it("bails when the edit spans a soft-wrap separator (two slots)", () => { + const run = makeParagraph(["aaa", "bbb", "ccc", "ddd"], [" ", "\n", " "]); + const prev = run.text; // "aaa bbb\nccc ddd" + // Delete the soft-wrap space between "ccc" and "ddd" (merges two slots). + const next = "aaa bbb\ncccddd"; + expect(planParagraphEdit(run, prev, next)).toBeNull(); + }); + + it("bails when slot ranges don't tile run.text (desynced model)", () => { + const run = makeParagraph(["aaa", "bbb"], ["\n"]); + // Corrupt run.text so the slot ranges no longer tile it. + run.text = "aaa bbb EXTRA"; + expect(planParagraphEdit(run, run.text, "Zaaa bbb EXTRA")).toBeNull(); + }); + + it("forces a fresh word-split re-emit when a mid-line edit would SetText whitespace (the „ bug)", () => { + // A whole line as ONE sub-run carrying spaces (LaTeX one-object-per-line). + const run = makeParagraph(["aaa bbb ccc", "ddd eee"], ["\n"]); + const prev = run.text; // "aaa bbb ccc\nddd eee" + const next = "aaa Xbb ccc\nddd eee"; // replace one char mid-line-0 + const plan = planParagraphEdit(run, prev, next); + expect(plan).not.toBeNull(); + const entry = plan?.perSlot.find((p) => p.slotIdx === 0); + expect(entry).toBeDefined(); + // null plan => the apply step fresh-emits this line (word-split), avoiding „. + expect(entry?.plan).toBeNull(); + expect(entry?.nextLine).toBe("aaa Xbb ccc"); + }); + + it("keeps the in-place modify fast path for a boundary edit on a single-word sub-run", () => { + // Deleting a char at a word's END keeps the surviving chars CONTIGUOUS in + // the new text, so the surgical single-object modify path is safe. + const run = makeParagraph(["hello", "world"], ["\n"]); + const prev = run.text; // "hello\nworld" + const next = "hello\nworl"; // delete trailing "d" + const plan = planParagraphEdit(run, prev, next); + expect(plan).not.toBeNull(); + const entry = plan?.perSlot.find((p) => p.slotIdx === 1); + // A non-null plan => surgical in-place edit kept (survivors contiguous). + expect(entry?.plan).not.toBeNull(); + }); + + it("re-emits a mid-word char replace instead of scrambling it (interior-insert guard)", () => { + // Replacing a char in the MIDDLE of a single word object ("world"->"worXd") + // deletes 'l' and inserts 'X' between the surviving 'r' and 'd'. + const run = makeParagraph(["hello", "world"], ["\n"]); + const prev = run.text; // "hello\nworld" + const next = "hello\nworXd"; + const plan = planParagraphEdit(run, prev, next); + expect(plan).not.toBeNull(); + const entry = plan?.perSlot.find((p) => p.slotIdx === 1); + expect(entry).toBeDefined(); + // null slot plan => the apply step fresh-emits this line (correct order). + expect(entry?.plan).toBeNull(); + expect(entry?.nextLine).toBe("worXd"); + }); + + it("handles an all-hard-break paragraph (initial-load shape) too", () => { + // Every visual line a hard break: this is the shape ParagraphGrouper builds + // at load. split == slots here, so it always worked. + const run = makeParagraph(["one", "two", "three"], ["\n", "\n"]); + const prev = run.text; + expect(prev).toBe("one\ntwo\nthree"); + const next = "one\ntwoX\nthree"; + const plan = planParagraphEdit(run, prev, next); + expect(plan).not.toBeNull(); + expect(plan?.nextLines).toEqual(["one", "twoX", "three"]); + expect(plan?.perSlot.map((p) => p.slotIdx)).toEqual([1]); + }); +}); diff --git a/frontend/editor/src/core/tools/pdfTextEditor/__tests__/PdfiumPageRenderer.test.ts b/frontend/editor/src/core/tools/pdfTextEditor/__tests__/PdfiumPageRenderer.test.ts new file mode 100644 index 0000000000..5bf31df486 --- /dev/null +++ b/frontend/editor/src/core/tools/pdfTextEditor/__tests__/PdfiumPageRenderer.test.ts @@ -0,0 +1,42 @@ +import { describe, expect, it } from "vitest"; +import { PdfiumPageRenderer } from "@app/tools/pdfTextEditor/pdfium/PdfiumPageRenderer"; + +// A4 in PDF points. +const A4_W = 595; +const A4_H = 842; + +describe("PdfiumPageRenderer.deviceScale", () => { + it("multiplies the zoom scale by the display ratio", () => { + expect(PdfiumPageRenderer.deviceScale(A4_W, A4_H, 1.5, 2)).toBeCloseTo(3); + }); + + it("treats a 1x display as a plain zoom scale", () => { + expect(PdfiumPageRenderer.deviceScale(A4_W, A4_H, 1.5, 1)).toBeCloseTo(1.5); + }); + + it("never renders BELOW the zoom scale on a sub-1x ratio", () => { + // Browser zoomed out below 100%: upscaling would soften, so hold at 1x. + expect(PdfiumPageRenderer.deviceScale(A4_W, A4_H, 1.5, 0.8)).toBeCloseTo( + 1.5, + ); + }); + + it("caps the ratio at 3 - beyond that is memory, not sharpness", () => { + expect(PdfiumPageRenderer.deviceScale(A4_W, A4_H, 1, 4)).toBeCloseTo(3); + }); + + it("clamps a poster page to the pixel budget", () => { + // 36x48in poster: 2592x3456pt. Unclamped 4x zoom on a 2x display would be + // a 573MB bitmap; the budget holds one page under ~128MB of RGBA. + const scale = PdfiumPageRenderer.deviceScale(2592, 3456, 4, 2); + const { width, height } = PdfiumPageRenderer.rasterSize(2592, 3456, scale); + expect(width * height).toBeLessThanOrEqual(32_000_000 * 1.01); + expect(scale).toBeLessThan(8); + expect(scale).toBeGreaterThan(1); + }); + + it("keeps ordinary pages essentially unclamped at max zoom on 2x", () => { + // A4 at 8x sits right on the pixel budget, so the cap shaves ~0.01. + expect(PdfiumPageRenderer.deviceScale(A4_W, A4_H, 4, 2)).toBeCloseTo(8, 1); + }); +}); diff --git a/frontend/editor/src/core/tools/pdfTextEditor/__tests__/ReplaceImageCommand.test.ts b/frontend/editor/src/core/tools/pdfTextEditor/__tests__/ReplaceImageCommand.test.ts new file mode 100644 index 0000000000..ff432689f8 --- /dev/null +++ b/frontend/editor/src/core/tools/pdfTextEditor/__tests__/ReplaceImageCommand.test.ts @@ -0,0 +1,314 @@ +import { describe, it, expect } from "vitest"; +import { ReplaceImageCommand } from "@app/tools/pdfTextEditor/commands/ReplaceImageCommand"; +import { Page } from "@app/tools/pdfTextEditor/model/Page"; +import { ImageObject } from "@app/tools/pdfTextEditor/model/ImageObject"; +import type { EditorDocument } from "@app/tools/pdfTextEditor/model/EditorDocument"; +import type { Affine, PageRect } from "@app/tools/pdfTextEditor/types"; + +const OLD_PTR = 42; +/** 90-degree rotated placement: a naive (w,0,0,h,x,y) rebuild would flip it. */ +const ROTATED: Affine = { a: 0, b: 120, c: -80, d: 0, e: 300, f: 40 }; +const BOX: PageRect = { x: 220, y: 40, width: 80, height: 120 }; + +interface FakeModule { + objs: number[]; + destroyed: number[]; + /** [objPtr, a, b, c, d, e, f] per FPDFImageObj_SetMatrix call. */ + matrixCalls: number[][]; + /** Same shape, but recorded from the FS_MATRIX struct fallback. */ + structMatrixCalls: number[][]; + newImageObjs: number; + bitmapsCreated: number; + jpegLoads: number; + generateCalls: number; + module: EditorDocument["module"]; +} + +/** Stub PDFium: page objects are a pointer array (index 0 = bottom). */ +function fakePdfium( + objs: number[], + opts: { + imageMatrixSetter?: boolean; + insertAtIndex?: boolean; + jpeg?: boolean; + } = {}, +): FakeModule { + const heap = new ArrayBuffer(64 * 1024); + const view = new DataView(heap); + const state: FakeModule = { + objs, + destroyed: [], + matrixCalls: [], + structMatrixCalls: [], + newImageObjs: 0, + bitmapsCreated: 0, + jpegLoads: 0, + generateCalls: 0, + module: null as unknown as EditorDocument["module"], + }; + let nextPtr = 1000; + let brk = 64; + let bitmapWidth = 0; + + const module: Record = { + FPDFPage_CountObjects: () => objs.length, + FPDFPage_GetObject: (_p: number, i: number) => objs[i] ?? 0, + FPDFPage_RemoveObject: (_p: number, ptr: number) => { + const i = objs.indexOf(ptr); + if (i < 0) return false; + objs.splice(i, 1); + return true; + }, + FPDFPage_InsertObject: (_p: number, ptr: number) => { + objs.push(ptr); + }, + FPDFPageObj_Destroy: (ptr: number) => { + state.destroyed.push(ptr); + }, + FPDFPageObj_NewImageObj: () => { + state.newImageObjs += 1; + nextPtr += 1; + return nextPtr; + }, + FPDFBitmap_Create: (w: number) => { + state.bitmapsCreated += 1; + bitmapWidth = w; + return 500; + }, + FPDFBitmap_GetBuffer: () => 4096, + FPDFBitmap_GetStride: () => bitmapWidth * 4, + FPDFBitmap_Destroy: () => undefined, + FPDFImageObj_SetBitmap: () => true, + FPDFPageObj_SetMatrix: (obj: number, ptr: number) => { + const vals: number[] = [obj]; + for (let i = 0; i < 6; i++) vals.push(view.getFloat32(ptr + i * 4, true)); + state.structMatrixCalls.push(vals); + return true; + }, + FPDFPage_GenerateContent: () => { + state.generateCalls += 1; + }, + pdfium: { + setValue: (ptr: number, value: number, type: string) => { + if (type === "float") view.setFloat32(ptr, value, true); + else view.setInt32(ptr, value, true); + }, + wasmExports: { + malloc: (size: number) => { + const p = brk; + brk += size; + return p; + }, + free: () => undefined, + memory: { buffer: heap }, + }, + HEAPU8: new Uint8Array(heap), + addFunction: () => 7, + removeFunction: () => undefined, + }, + }; + if (opts.imageMatrixSetter !== false) { + module.FPDFImageObj_SetMatrix = ( + obj: number, + a: number, + b: number, + c: number, + d: number, + e: number, + f: number, + ) => { + state.matrixCalls.push([obj, a, b, c, d, e, f]); + return true; + }; + } + if (opts.insertAtIndex !== false) { + module.FPDFPage_InsertObjectAtIndex = ( + _p: number, + ptr: number, + index: number, + ) => { + objs.splice(index, 0, ptr); + return true; + }; + } + if (opts.jpeg) { + module.FPDFImageObj_LoadJpegFileInline = () => { + state.jpegLoads += 1; + return true; + }; + } + state.module = module as unknown as EditorDocument["module"]; + return state; +} + +function pageWithImage(): Page { + const page = new Page({ index: 0, pagePtr: 1, width: 600, height: 800 }); + page.setImages([ + new ImageObject({ + id: "img1", + pageIndex: 0, + pdfiumObjPtr: OLD_PTR, + bounds: { ...BOX }, + matrix: { ...ROTATED }, + }), + ]); + return page; +} + +function fakeDoc(fake: FakeModule, page: Page): EditorDocument { + return { + module: fake.module, + docPtr: 9, + page: () => page, + } as unknown as EditorDocument; +} + +/** Replacement pixels with a deliberately different aspect ratio (4x1). */ +const REPLACEMENT = { + rgba: new Uint8Array(4 * 1 * 4).fill(200), + width: 4, + height: 1, +}; + +function makeCommand(jpegBytes?: Uint8Array): ReplaceImageCommand { + return new ReplaceImageCommand({ + pageIndex: 0, + imageId: "img1", + image: REPLACEMENT, + jpegBytes, + }); +} + +describe("ReplaceImageCommand", () => { + it("keeps the existing placement matrix exactly, whatever the new pixel ratio", () => { + const page = pageWithImage(); + const fake = fakePdfium([OLD_PTR]); + makeCommand().apply(fakeDoc(fake, page)); + + const img = page.images[0]; + expect(img.pdfiumObjPtr).not.toBe(OLD_PTR); + // The written matrix is the captured one, NOT a rebuilt (w,0,0,h,x,y). + expect(fake.matrixCalls).toEqual([ + [img.pdfiumObjPtr, 0, 120, -80, 0, 300, 40], + ]); + expect(img.matrix).toEqual(ROTATED); + expect(img.bounds).toEqual(BOX); + }); + + it("puts the replacement back in the old object's z-order slot", () => { + const page = pageWithImage(); + const fake = fakePdfium([7, OLD_PTR, 9]); + makeCommand().apply(fakeDoc(fake, page)); + + expect(fake.objs).toEqual([7, page.images[0].pdfiumObjPtr, 9]); + }); + + it("detaches the old object WITHOUT destroying it, so undo is not a use-after-free", () => { + const page = pageWithImage(); + const fake = fakePdfium([OLD_PTR]); + makeCommand().apply(fakeDoc(fake, page)); + + expect(fake.objs).not.toContain(OLD_PTR); + expect(fake.destroyed).toEqual([]); + }); + + it("marks the page dirty and needing regeneration instead of generating content", () => { + const page = pageWithImage(); + const fake = fakePdfium([OLD_PTR]); + const rev0 = page.revision; + makeCommand().apply(fakeDoc(fake, page)); + + expect(page.revision).toBeGreaterThan(rev0); + expect(page.needsGenerateContent).toBe(true); + expect(page.images[0].dirty).toBe(true); + expect(fake.generateCalls).toBe(0); + }); + + it("revert restores the original object, matrix and bounds", () => { + const page = pageWithImage(); + const fake = fakePdfium([7, OLD_PTR, 9]); + const doc = fakeDoc(fake, page); + const cmd = makeCommand(); + cmd.apply(doc); + const replacement = page.images[0].pdfiumObjPtr; + + cmd.revert(doc); + + expect(fake.objs).toEqual([7, OLD_PTR, 9]); + expect(page.images[0].pdfiumObjPtr).toBe(OLD_PTR); + expect(page.images[0].matrix).toEqual(ROTATED); + expect(page.images[0].bounds).toEqual(BOX); + // The replacement survives for redo, so it must not have been destroyed. + expect(fake.destroyed).not.toContain(replacement); + expect(page.needsGenerateContent).toBe(true); + }); + + it("redo re-attaches the same replacement instead of embedding twice", () => { + const page = pageWithImage(); + const fake = fakePdfium([7, OLD_PTR, 9]); + const doc = fakeDoc(fake, page); + const cmd = makeCommand(); + cmd.apply(doc); + const replacement = page.images[0].pdfiumObjPtr; + cmd.revert(doc); + cmd.apply(doc); + + expect(fake.newImageObjs).toBe(1); + expect(fake.objs).toEqual([7, replacement, 9]); + expect(page.images[0].pdfiumObjPtr).toBe(replacement); + expect(page.images[0].matrix).toEqual(ROTATED); + }); + + it("falls back to the FS_MATRIX setter when FPDFImageObj_SetMatrix is missing", () => { + const page = pageWithImage(); + const fake = fakePdfium([OLD_PTR], { imageMatrixSetter: false }); + makeCommand().apply(fakeDoc(fake, page)); + + const written = fake.structMatrixCalls.at(-1); + expect(written?.slice(1)).toEqual([0, 120, -80, 0, 300, 40]); + expect(page.images[0].matrix).toEqual(ROTATED); + }); + + it("embeds supplied JPEG bytes as-is rather than re-encoding the bitmap", () => { + const page = pageWithImage(); + const fake = fakePdfium([OLD_PTR], { jpeg: true }); + makeCommand(new Uint8Array([0xff, 0xd8, 0xff, 0xd9])).apply( + fakeDoc(fake, page), + ); + + expect(fake.jpegLoads).toBe(1); + expect(fake.bitmapsCreated).toBe(0); + expect(fake.matrixCalls.at(-1)?.slice(1)).toEqual([ + 0, 120, -80, 0, 300, 40, + ]); + }); + + it("is a no-op for an unknown image id", () => { + const page = pageWithImage(); + const fake = fakePdfium([OLD_PTR]); + const cmd = new ReplaceImageCommand({ + pageIndex: 0, + imageId: "missing", + image: REPLACEMENT, + }); + cmd.apply(fakeDoc(fake, page)); + cmd.revert(fakeDoc(fake, page)); + + expect(fake.objs).toEqual([OLD_PTR]); + expect(fake.newImageObjs).toBe(0); + expect(page.revision).toBe(0); + }); + + it("leaves the page untouched when the embed fails", () => { + const page = pageWithImage(); + const fake = fakePdfium([OLD_PTR]); + ( + fake.module as unknown as Record + ).FPDFPageObj_NewImageObj = () => 0; + makeCommand().apply(fakeDoc(fake, page)); + + expect(fake.objs).toEqual([OLD_PTR]); + expect(page.images[0].pdfiumObjPtr).toBe(OLD_PTR); + expect(page.needsGenerateContent).toBe(false); + }); +}); diff --git a/frontend/editor/src/core/tools/pdfTextEditor/__tests__/affine.test.ts b/frontend/editor/src/core/tools/pdfTextEditor/__tests__/affine.test.ts new file mode 100644 index 0000000000..6fff639295 --- /dev/null +++ b/frontend/editor/src/core/tools/pdfTextEditor/__tests__/affine.test.ts @@ -0,0 +1,126 @@ +import { describe, it, expect } from "vitest"; +import { + composeAffine, + invertAffine, + imageMatrixBounds, + remapImageMatrix, + transformRectAABB, +} from "@app/tools/pdfTextEditor/model/affine"; +import { DisplayTransform } from "@app/tools/pdfTextEditor/model/DisplayTransform"; +import type { Affine, PageRect } from "@app/tools/pdfTextEditor/types"; + +const IDENTITY: Affine = { a: 1, b: 0, c: 0, d: 1, e: 0, f: 0 }; + +function expectAffineClose(got: Affine, want: Affine): void { + for (const k of ["a", "b", "c", "d", "e", "f"] as const) { + expect(got[k]).toBeCloseTo(want[k], 4); + } +} + +describe("affine helpers", () => { + it("invertAffine inverts a rotation+translation, identity on singular", () => { + const t: Affine = { a: 0, b: -1, c: 1, d: 0, e: 5, f: 7 }; + const round = composeAffine(t, invertAffine(t)); + expectAffineClose(round, IDENTITY); + // Degenerate (zero linear part) -> identity rather than NaN. + expectAffineClose( + invertAffine({ a: 0, b: 0, c: 0, d: 0, e: 3, f: 4 }), + IDENTITY, + ); + }); + + it("imageMatrixBounds is the AABB of the unit square under the matrix", () => { + // 90deg-rotated 200x100 image -> 100 wide x 200 tall AABB. + const m: Affine = { a: 0, b: 200, c: -100, d: 0, e: 562, f: 100 }; + const b = imageMatrixBounds(m); + expect(b).toEqual({ x: 462, y: 100, width: 100, height: 200 }); + }); +}); + +describe("remapImageMatrix - unrotated page stays byte-identical", () => { + const display = IDENTITY; // CropBox==MediaBox, /Rotate 0 + + it("moving an axis-aligned image only translates it", () => { + const prev: Affine = { a: 100, b: 0, c: 0, d: 50, e: 10, f: 20 }; + const prevBounds: PageRect = { x: 10, y: 20, width: 100, height: 50 }; + const nextBounds: PageRect = { x: 60, y: 80, width: 100, height: 50 }; + expectAffineClose(remapImageMatrix(prev, prevBounds, nextBounds, display), { + a: 100, + b: 0, + c: 0, + d: 50, + e: 60, + f: 80, + }); + }); + + it("resizing an axis-aligned image rebuilds (w,0,0,h,x,y)", () => { + const prev: Affine = { a: 100, b: 0, c: 0, d: 50, e: 10, f: 20 }; + const prevBounds: PageRect = { x: 10, y: 20, width: 100, height: 50 }; + const nextBounds: PageRect = { x: 10, y: 20, width: 200, height: 100 }; + expectAffineClose(remapImageMatrix(prev, prevBounds, nextBounds, display), { + a: 200, + b: 0, + c: 0, + d: 100, + e: 10, + f: 20, + }); + }); +}); + +describe("remapImageMatrix - /Rotate 90 landscape page preserves orientation", () => { + // Portrait MediaBox 612x792 displayed landscape via /Rotate 90. + const display = DisplayTransform.fromCropAndRotate( + 0, + 0, + 612, + 792, + 1, + 792, + 612, + ); + // An image that displays upright as 200 wide x 100 tall has this raw matrix + // (rotated 90deg in raw space) and a 100x200 raw AABB. + const prev: Affine = { a: 0, b: 200, c: -100, d: 0, e: 562, f: 100 }; + const prevBounds: PageRect = { x: 462, y: 100, width: 100, height: 200 }; + + it("a no-op move returns the original matrix unchanged (no flip)", () => { + const next = remapImageMatrix(prev, prevBounds, prevBounds, display); + expectAffineClose(next, prev); + }); + + it("a move keeps the image's linear part (orientation + aspect) intact", () => { + // Drag the displayed image by (+30, +40) px in display space. That is a + // raw-space translation of A^-1 * (30,40) = (-40, 30). + const nextBounds: PageRect = { x: 422, y: 130, width: 100, height: 200 }; + const next = remapImageMatrix(prev, prevBounds, nextBounds, display); + // Linear part is byte-stable -> the image is NOT re-oriented by a move. + expect(next.a).toBeCloseTo(prev.a, 4); + expect(next.b).toBeCloseTo(prev.b, 4); + expect(next.c).toBeCloseTo(prev.c, 4); + expect(next.d).toBeCloseTo(prev.d, 4); + expect(next.e).toBeCloseTo(522, 4); + expect(next.f).toBeCloseTo(130, 4); + + // And the image still DISPLAYS as 200 wide x 100 tall (landscape upright), + // not the swapped 100x200 the old counter-rotate path produced. + const dispBox = transformRectAABB(display, imageMatrixBounds(next)); + expect(dispBox.width).toBeCloseTo(200, 3); + expect(dispBox.height).toBeCloseTo(100, 3); + }); + + it("a uniform resize scales display footprint without swapping w/h", () => { + // Halve the displayed size: 200x100 -> 100x50, anchored at same display + // lower-left. The displayed AABB stays landscape (wider than tall). + const half = remapImageMatrix( + prev, + prevBounds, + { x: 512, y: 100, width: 50, height: 100 }, + display, + ); + const dispBox = transformRectAABB(display, imageMatrixBounds(half)); + expect(dispBox.width).toBeCloseTo(100, 3); + expect(dispBox.height).toBeCloseTo(50, 3); + }); +}); diff --git a/frontend/editor/src/core/tools/pdfTextEditor/__tests__/canvasBackground.test.ts b/frontend/editor/src/core/tools/pdfTextEditor/__tests__/canvasBackground.test.ts new file mode 100644 index 0000000000..90b420160b --- /dev/null +++ b/frontend/editor/src/core/tools/pdfTextEditor/__tests__/canvasBackground.test.ts @@ -0,0 +1,107 @@ +import { describe, expect, it } from "vitest"; +import { + sampleRunBackground, + toOpaqueCss, +} from "@app/tools/pdfTextEditor/util/canvasBackground"; + +type Pixel = [number, number, number]; + +function stubCanvas( + width: number, + height: number, + pixelAt: (x: number, y: number) => Pixel, +): HTMLCanvasElement { + const ctx = { + getImageData: (sx: number, sy: number, sw: number, sh: number) => { + const data = new Uint8ClampedArray(sw * sh * 4); + for (let y = 0; y < sh; y += 1) { + for (let x = 0; x < sw; x += 1) { + const [r, g, b] = pixelAt(sx + x, sy + y); + const off = (y * sw + x) * 4; + data[off] = r; + data[off + 1] = g; + data[off + 2] = b; + data[off + 3] = 255; + } + } + return { data }; + }, + }; + return { + width, + height, + getContext: () => ctx, + } as unknown as HTMLCanvasElement; +} + +const RECT = { x: 10, y: 10, width: 30, height: 20 }; + +describe("sampleRunBackground", () => { + it("returns pure white for a white page", () => { + const canvas = stubCanvas(100, 100, () => [255, 255, 255]); + expect(sampleRunBackground(canvas, RECT)).toEqual({ + r: 255, + g: 255, + b: 255, + }); + }); + + it("serialises that white as an opaque rgb() string", () => { + const canvas = stubCanvas(100, 100, () => [255, 255, 255]); + expect(toOpaqueCss(sampleRunBackground(canvas, RECT)!)).toBe( + "rgb(255, 255, 255)", + ); + }); + + it("returns the exact colour of a flat coloured page", () => { + const canvas = stubCanvas(100, 100, () => [183, 28, 28]); + expect(sampleRunBackground(canvas, RECT)).toEqual({ r: 183, g: 28, b: 28 }); + }); + + it("averages the real pixels of the winning bucket, rounding to integers", () => { + const canvas = stubCanvas(100, 100, (_x, y) => + y % 2 === 0 ? [250, 250, 250] : [255, 255, 255], + ); + expect(sampleRunBackground(canvas, RECT)).toEqual({ + r: 253, + g: 253, + b: 253, + }); + }); + + it("ignores a minority colour in the sampled strips", () => { + const canvas = stubCanvas(100, 100, (x) => + x < 14 ? [0, 0, 0] : [240, 200, 100], + ); + expect(sampleRunBackground(canvas, RECT)).toEqual({ + r: 240, + g: 200, + b: 100, + }); + }); + + it("returns null for a degenerate rect", () => { + const canvas = stubCanvas(100, 100, () => [255, 255, 255]); + expect(sampleRunBackground(canvas, { ...RECT, width: 0 })).toBeNull(); + expect(sampleRunBackground(canvas, { ...RECT, height: 0 })).toBeNull(); + }); + + it("returns null when the canvas cannot be read", () => { + const noCtx = { width: 100, height: 100, getContext: () => null }; + expect( + sampleRunBackground(noCtx as unknown as HTMLCanvasElement, RECT), + ).toBeNull(); + const tainted = { + width: 100, + height: 100, + getContext: () => ({ + getImageData: () => { + throw new Error("tainted"); + }, + }), + }; + expect( + sampleRunBackground(tainted as unknown as HTMLCanvasElement, RECT), + ).toBeNull(); + }); +}); diff --git a/frontend/editor/src/core/tools/pdfTextEditor/__tests__/cloneParagraphLineSlot.test.ts b/frontend/editor/src/core/tools/pdfTextEditor/__tests__/cloneParagraphLineSlot.test.ts new file mode 100644 index 0000000000..77d7595a2e --- /dev/null +++ b/frontend/editor/src/core/tools/pdfTextEditor/__tests__/cloneParagraphLineSlot.test.ts @@ -0,0 +1,53 @@ +import { describe, it, expect } from "vitest"; +import { + cloneParagraphLineSlot, + type ParagraphLineSlot, +} from "@app/tools/pdfTextEditor/model/TextRun"; + +function mkSlot(): ParagraphLineSlot { + return { + startChar: 0, + endChar: 5, + baselineY: 100, + matrixE: 10, + containerPtr: 0, + fontId: "pdf:1:Helvetica", + fontSize: 12, + fontSubset: false, + mergedFromPtrs: [11, 22], + mergedFromTexts: ["He", "llo"], + mergedFromBounds: [ + { x: 0, right: 5 }, + { x: 5, right: 10 }, + ], + mergedFromCharStarts: [0, 2], + }; +} + +describe("cloneParagraphLineSlot", () => { + it("produces an equal but independent copy", () => { + const src = mkSlot(); + const copy = cloneParagraphLineSlot(src); + expect(copy).toEqual(src); + // Nested arrays/objects must be fresh references, not shared. + expect(copy.mergedFromPtrs).not.toBe(src.mergedFromPtrs); + expect(copy.mergedFromTexts).not.toBe(src.mergedFromTexts); + expect(copy.mergedFromBounds).not.toBe(src.mergedFromBounds); + expect(copy.mergedFromBounds[0]).not.toBe(src.mergedFromBounds[0]); + expect(copy.mergedFromCharStarts).not.toBe(src.mergedFromCharStarts); + }); + + it("mutating the copy never touches the source (snapshot-safety)", () => { + const src = mkSlot(); + const snapshot = cloneParagraphLineSlot(src); + // Simulate a later in-place edit of the live slot. + src.mergedFromPtrs.push(33); + src.mergedFromTexts[0] = "XX"; + src.mergedFromBounds[0].right = 999; + src.mergedFromCharStarts[1] = 7; + expect(snapshot.mergedFromPtrs).toEqual([11, 22]); + expect(snapshot.mergedFromTexts).toEqual(["He", "llo"]); + expect(snapshot.mergedFromBounds[0].right).toBe(5); + expect(snapshot.mergedFromCharStarts).toEqual([0, 2]); + }); +}); diff --git a/frontend/editor/src/core/tools/pdfTextEditor/__tests__/deviceFontEmbed.test.ts b/frontend/editor/src/core/tools/pdfTextEditor/__tests__/deviceFontEmbed.test.ts new file mode 100644 index 0000000000..e93948807f --- /dev/null +++ b/frontend/editor/src/core/tools/pdfTextEditor/__tests__/deviceFontEmbed.test.ts @@ -0,0 +1,453 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"; +import { + deviceFontEmitCount, + emitDeviceFontTextObject, + ensureDeviceFontReady, + isDeviceFontEmbedded, + isDeviceFontReady, + loadDeviceFontInto, + resetDeviceFontEmbedCache, +} from "@app/tools/pdfTextEditor/util/deviceFontEmbed"; +import { + loadLocalFontBytes, + pickLocalFontFace, + resetLocalFontsCache, +} from "@app/tools/pdfTextEditor/util/localFonts"; +import type { LocalFont } from "@app/tools/pdfTextEditor/util/localFonts"; +import type { FontRef } from "@app/tools/pdfTextEditor/model/FontRef"; +import type { EditorDocument } from "@app/tools/pdfTextEditor/model/EditorDocument"; +import { Page } from "@app/tools/pdfTextEditor/model/Page"; + +type QueryStub = () => Promise; + +/** One FontData-shaped face; `bytes` null means `.blob()` is absent. */ +function face( + family: string, + style: string, + bytes: Uint8Array | null, + blobImpl?: () => Promise, +): Record { + const entry: Record = { + family, + style, + fullName: `${family} ${style}`, + postscriptName: `${family.replace(/\s+/g, "")}-${style.replace(/\s+/g, "")}`, + }; + if (blobImpl) entry.blob = blobImpl; + else if (bytes) { + entry.blob = async () => ({ + arrayBuffer: async () => + bytes.buffer.slice( + bytes.byteOffset, + bytes.byteOffset + bytes.byteLength, + ), + }); + } + return entry; +} + +function setQuery(stub: QueryStub | null): void { + const w = window as unknown as { queryLocalFonts?: QueryStub }; + if (stub) w.queryLocalFonts = stub; + else delete w.queryLocalFonts; +} + +function plainFont(family: string, style: string): LocalFont { + return { + family, + style, + fullName: `${family} ${style}`, + postscriptName: `${family.replace(/\s+/g, "")}-${style.replace(/\s+/g, "")}`, + }; +} + +/** Not a real font file: parseTrueTypeCmap gives up, so coverage fails open. */ +const FAKE_FONT_BYTES = new Uint8Array([1, 2, 3, 4, 5, 6, 7, 8]); + +interface FakeModuleOptions { + loadFont?: ( + doc: number, + data: number, + size: number, + type: number, + cid: boolean, + ) => number; + /** Right edge the emitted object measures at (drives the width check). */ + rightEdge?: number; + omitCreateTextObj?: boolean; +} + +interface FakeHarness { + doc: EditorDocument; + page: Page; + calls: { + loadFont: number; + createTextObj: number; + inserted: number[]; + removed: number[]; + destroyed: number[]; + freed: number[]; + malloced: number[]; + }; + ownedFonts: Map; +} + +function fakeHarness(options: FakeModuleOptions = {}): FakeHarness { + const calls = { + loadFont: 0, + createTextObj: 0, + inserted: [] as number[], + removed: [] as number[], + destroyed: [] as number[], + freed: [] as number[], + malloced: [] as number[], + }; + const heap = new Uint8Array(4096); + let nextPtr = 16; + const module = { + pdfium: { + HEAPU8: heap, + stringToUTF16: () => undefined, + getValue: () => options.rightEdge ?? 100, + wasmExports: { + malloc: (n: number) => { + const ptr = nextPtr; + nextPtr += Math.max(4, n); + calls.malloced.push(ptr); + return ptr; + }, + free: (p: number) => { + calls.freed.push(p); + }, + }, + }, + FPDFText_LoadFont: ( + doc: number, + data: number, + size: number, + type: number, + cid: boolean, + ) => { + calls.loadFont += 1; + return options.loadFont + ? options.loadFont(doc, data, size, type, cid) + : 900; + }, + FPDFFont_Close: () => undefined, + FPDFPageObj_CreateTextObj: () => { + calls.createTextObj += 1; + return 500 + calls.createTextObj; + }, + FPDFText_SetText: () => true, + FPDFPageObj_SetFillColor: () => true, + FPDFPageObj_Transform: () => true, + FPDFPage_InsertObject: (_page: number, ptr: number) => { + calls.inserted.push(ptr); + }, + FPDFPage_RemoveObject: (_page: number, ptr: number) => { + calls.removed.push(ptr); + return true; + }, + FPDFPageObj_Destroy: (ptr: number) => { + calls.destroyed.push(ptr); + }, + FPDFPageObj_GetBounds: () => true, + }; + if (options.omitCreateTextObj) { + delete (module as { FPDFPageObj_CreateTextObj?: unknown }) + .FPDFPageObj_CreateTextObj; + } + const ownedFonts = new Map(); + const doc = { + module, + docPtr: 7, + registerOwnedFont: (font: FontRef) => { + ownedFonts.set(font.id, font); + }, + ownedFont: (id: string) => ownedFonts.get(id), + } as unknown as EditorDocument; + const page = new Page({ index: 0, pagePtr: 3, width: 200, height: 200 }); + return { doc, page, calls, ownedFonts }; +} + +const FILL = { r: 0, g: 0, b: 0, a: 255 }; + +function emit(harness: FakeHarness, family: string, text = "Hi"): number { + return emitDeviceFontTextObject( + harness.doc, + harness.page, + family, + text, + 12, + FILL, + 10, + 20, + ); +} + +beforeEach(() => { + resetLocalFontsCache(); + resetDeviceFontEmbedCache(); + setQuery(null); +}); + +afterEach(() => { + resetLocalFontsCache(); + resetDeviceFontEmbedCache(); + setQuery(null); +}); + +describe("pickLocalFontFace", () => { + const faces = [ + plainFont("Segoe UI", "Bold"), + plainFont("Segoe UI", "Italic"), + plainFont("Segoe UI", "Bold Italic"), + plainFont("Segoe UI", "Regular"), + plainFont("Segoe UI", "Light"), + plainFont("Arial", "Regular"), + ]; + + it("prefers the upright regular cut for a bare family name", () => { + expect(pickLocalFontFace(faces, "Segoe UI")?.style).toBe("Regular"); + }); + + it("respects bold and italic carried in the requested name", () => { + expect(pickLocalFontFace(faces, "Segoe UI Bold")?.style).toBe("Bold"); + expect(pickLocalFontFace(faces, "Segoe UI Italic")?.style).toBe("Italic"); + expect(pickLocalFontFace(faces, "Segoe UI Bold Italic")?.style).toBe( + "Bold Italic", + ); + }); + + it("matches case- and separator-insensitively", () => { + expect(pickLocalFontFace(faces, "segoe-ui")?.family).toBe("Segoe UI"); + }); + + it("keeps a family whose own name contains a style word", () => { + const withBlack = [ + plainFont("Arial Black", "Regular"), + plainFont("Arial", "Bold"), + ]; + expect(pickLocalFontFace(withBlack, "Arial Black")?.family).toBe( + "Arial Black", + ); + }); + + it("returns null when nothing matches", () => { + expect(pickLocalFontFace(faces, "Comic Sans MS")).toBeNull(); + expect(pickLocalFontFace([], "Segoe UI")).toBeNull(); + }); +}); + +describe("loadLocalFontBytes", () => { + it("returns null when the API is unsupported", async () => { + await expect(loadLocalFontBytes("Segoe UI")).resolves.toBeNull(); + expect(isDeviceFontReady("Segoe UI")).toBe(false); + }); + + it("returns null when the permission prompt is denied", async () => { + const denied = new Error("denied"); + denied.name = "NotAllowedError"; + setQuery(vi.fn().mockRejectedValue(denied)); + await expect(ensureDeviceFontReady("Segoe UI")).resolves.toBe(false); + }); + + it("returns null when the face exposes no blob()", async () => { + setQuery( + vi.fn().mockResolvedValue([face("Segoe UI", "Regular", null)]), + ); + await expect(loadLocalFontBytes("Segoe UI")).resolves.toBeNull(); + }); + + it("returns null when the blob read rejects", async () => { + setQuery( + vi + .fn() + .mockResolvedValue([ + face("Segoe UI", "Regular", null, () => + Promise.reject(new Error("blob failed")), + ), + ]), + ); + await expect(loadLocalFontBytes("Segoe UI")).resolves.toBeNull(); + }); + + it("returns null when the blob has no arrayBuffer()", async () => { + setQuery( + vi + .fn() + .mockResolvedValue([ + face("Segoe UI", "Regular", null, async () => ({})), + ]), + ); + await expect(loadLocalFontBytes("Segoe UI")).resolves.toBeNull(); + }); + + it("reads the bytes once per family and caches them for the session", async () => { + const blob = vi.fn(async () => ({ + arrayBuffer: async () => FAKE_FONT_BYTES.buffer.slice(0), + })); + const query = vi + .fn() + .mockResolvedValue([face("Segoe UI", "Regular", null, blob)]); + setQuery(query); + + const [first, second] = await Promise.all([ + loadLocalFontBytes("Segoe UI"), + loadLocalFontBytes("Segoe UI"), + ]); + const third = await loadLocalFontBytes("Segoe UI"); + + expect(first).toBeInstanceOf(Uint8Array); + expect(second).toBe(first); + expect(third).toBe(first); + expect(query).toHaveBeenCalledTimes(1); + expect(blob).toHaveBeenCalledTimes(1); + expect(isDeviceFontReady("segoe ui")).toBe(true); + }); + + it("does not cache a failure, so a later read can still succeed", async () => { + setQuery(vi.fn().mockResolvedValue([])); + await expect(loadLocalFontBytes("Segoe UI")).resolves.toBeNull(); + + resetLocalFontsCache(); + setQuery( + vi + .fn() + .mockResolvedValue([face("Segoe UI", "Regular", FAKE_FONT_BYTES)]), + ); + await expect(loadLocalFontBytes("Segoe UI")).resolves.toBeInstanceOf( + Uint8Array, + ); + }); +}); + +describe("loadDeviceFontInto", () => { + async function warm(family = "Segoe UI"): Promise { + setQuery( + vi + .fn() + .mockResolvedValue([face(family, "Regular", FAKE_FONT_BYTES)]), + ); + await ensureDeviceFontReady(family); + } + + it("returns 0 while the byte cache is cold", () => { + const harness = fakeHarness(); + expect(loadDeviceFontInto(harness.doc, "Segoe UI")).toBe(0); + expect(harness.calls.loadFont).toBe(0); + }); + + it("embeds once per document and reuses the handle", async () => { + await warm(); + const harness = fakeHarness(); + + expect(loadDeviceFontInto(harness.doc, "Segoe UI")).toBe(900); + expect(loadDeviceFontInto(harness.doc, "Segoe UI")).toBe(900); + expect(harness.calls.loadFont).toBe(1); + expect(isDeviceFontEmbedded(harness.doc, "Segoe UI")).toBe(true); + }); + + it("embeds separately per document", async () => { + await warm(); + const a = fakeHarness(); + const b = fakeHarness(); + + loadDeviceFontInto(a.doc, "Segoe UI"); + loadDeviceFontInto(b.doc, "Segoe UI"); + + expect(a.calls.loadFont).toBe(1); + expect(b.calls.loadFont).toBe(1); + }); + + it("frees the buffer and never retries when PDFium refuses the font", async () => { + await warm(); + const harness = fakeHarness({ loadFont: () => 0 }); + + expect(loadDeviceFontInto(harness.doc, "Segoe UI")).toBe(0); + expect(loadDeviceFontInto(harness.doc, "Segoe UI")).toBe(0); + expect(harness.calls.loadFont).toBe(1); + expect(harness.calls.freed).toEqual(harness.calls.malloced); + expect(isDeviceFontEmbedded(harness.doc, "Segoe UI")).toBe(false); + }); + + it("frees the buffer when the binding throws", async () => { + await warm(); + const harness = fakeHarness({ + loadFont: () => { + throw new Error("wasm trap"); + }, + }); + + expect(loadDeviceFontInto(harness.doc, "Segoe UI")).toBe(0); + expect(harness.calls.freed).toEqual(harness.calls.malloced); + }); + + it("frees the font handle and its buffer through the owned FontRef", async () => { + await warm(); + const harness = fakeHarness(); + loadDeviceFontInto(harness.doc, "Segoe UI"); + const buffer = harness.calls.malloced[0]; + harness.calls.freed.length = 0; + + for (const font of harness.ownedFonts.values()) font.dispose(); + + expect(harness.calls.freed).toContain(buffer); + }); +}); + +describe("emitDeviceFontTextObject", () => { + async function warm(family = "Segoe UI"): Promise { + setQuery( + vi + .fn() + .mockResolvedValue([face(family, "Regular", FAKE_FONT_BYTES)]), + ); + await ensureDeviceFontReady(family); + } + + it("returns 0 without touching PDFium when the bytes are not cached", () => { + const harness = fakeHarness(); + expect(emit(harness, "Segoe UI")).toBe(0); + expect(harness.calls.createTextObj).toBe(0); + expect(deviceFontEmitCount(harness.doc, "Segoe UI")).toBe(0); + }); + + it("emits an inserted text object in the embedded face", async () => { + await warm(); + const harness = fakeHarness(); + + const ptr = emit(harness, "Segoe UI"); + + expect(ptr).toBeGreaterThan(0); + expect(harness.calls.inserted).toEqual([ptr]); + expect(harness.calls.removed).toEqual([]); + expect(deviceFontEmitCount(harness.doc, "Segoe UI")).toBe(1); + }); + + it("rejects an emit that rendered no width and cleans it up", async () => { + await warm(); + // Right edge equal to x: the face produced .notdef, not glyphs. + const harness = fakeHarness({ rightEdge: 10 }); + + const ptr = emit(harness, "Segoe UI"); + + expect(ptr).toBe(0); + expect(harness.calls.removed).toHaveLength(1); + expect(harness.calls.destroyed).toEqual(harness.calls.removed); + expect(deviceFontEmitCount(harness.doc, "Segoe UI")).toBe(0); + }); + + it("returns 0 when the CreateTextObj binding is missing", async () => { + await warm(); + const harness = fakeHarness({ omitCreateTextObj: true }); + expect(emit(harness, "Segoe UI")).toBe(0); + }); + + it("returns 0 for an unknown family and for empty text", async () => { + await warm(); + const harness = fakeHarness(); + expect(emit(harness, "Comic Sans MS")).toBe(0); + expect(emit(harness, "Segoe UI", "")).toBe(0); + }); +}); diff --git a/frontend/editor/src/core/tools/pdfTextEditor/__tests__/documentRisks.test.ts b/frontend/editor/src/core/tools/pdfTextEditor/__tests__/documentRisks.test.ts new file mode 100644 index 0000000000..c1a38b3062 --- /dev/null +++ b/frontend/editor/src/core/tools/pdfTextEditor/__tests__/documentRisks.test.ts @@ -0,0 +1,124 @@ +import { describe, it, expect } from "vitest"; +import { + detectSaveRisks, + hasSaveRisks, + describeSaveRisks, +} from "@app/tools/pdfTextEditor/util/documentRisks"; +import type { EditorDocument } from "@app/tools/pdfTextEditor/model/EditorDocument"; + +function mkDoc(opts: { + signatures?: number; + formType?: number; + throwOnSig?: boolean; + secHandlerRev?: number; + throwOnEncrypt?: boolean; +}): EditorDocument { + return { + docPtr: 1, + loadedPages: () => [{ pagePtr: 10 }], + module: { + FPDF_GetSignatureCount: () => { + if (opts.throwOnSig) throw new Error("no API"); + return opts.signatures ?? 0; + }, + FPDF_GetFormType: () => opts.formType ?? 0, + FPDF_GetSecurityHandlerRevision: () => { + if (opts.throwOnEncrypt) throw new Error("no API"); + return opts.secHandlerRev ?? -1; + }, + }, + } as unknown as EditorDocument; +} + +describe("detectSaveRisks", () => { + it("reports no risk for a plain document", () => { + const r = detectSaveRisks(mkDoc({})); + expect(r).toEqual({ + signatures: 0, + xfaForm: false, + encrypted: false, + droppedChars: [], + }); + expect(hasSaveRisks(r)).toBe(false); + }); + + it("flags digital signatures", () => { + const r = detectSaveRisks(mkDoc({ signatures: 2 })); + expect(r.signatures).toBe(2); + expect(hasSaveRisks(r)).toBe(true); + expect(describeSaveRisks(r)).toEqual([ + "This document carries 2 digital signatures. Your changes are appended as a new revision, so the signed version stays verifiable, but the document will report as modified since it was signed.", + ]); + }); + + it("flags XFA forms (formType 2/3) but not plain AcroForm (1)", () => { + expect(detectSaveRisks(mkDoc({ formType: 1 })).xfaForm).toBe(false); + expect(detectSaveRisks(mkDoc({ formType: 2 })).xfaForm).toBe(true); + expect(detectSaveRisks(mkDoc({ formType: 3 })).xfaForm).toBe(true); + }); + + it("singular wording for one signature", () => { + expect( + describeSaveRisks({ + signatures: 1, + xfaForm: false, + encrypted: false, + droppedChars: [], + }), + ).toEqual([ + "This document carries a digital signature. Your changes are appended as a new revision, so the signed version stays verifiable, but the document will report as modified since it was signed.", + ]); + }); + + it("flags characters dropped because no font could render them", () => { + const r = { + signatures: 0, + xfaForm: false, + encrypted: false, + droppedChars: ["中", "文"], + }; + expect(hasSaveRisks(r)).toBe(true); + expect(describeSaveRisks(r)).toEqual([ + "Some characters could not be embedded in any available font and were dropped: 中 文", + ]); + }); + + it("truncates a long dropped-char list with a +N more suffix", () => { + const dropped = Array.from({ length: 15 }, (_, i) => + String.fromCharCode(0x4e00 + i), + ); + const line = describeSaveRisks({ + signatures: 0, + xfaForm: false, + encrypted: false, + droppedChars: dropped, + })[0]; + expect(line).toContain("(+3 more)"); + }); + + it("clamps negative signature counts and survives a missing API", () => { + expect(detectSaveRisks(mkDoc({ signatures: -1 })).signatures).toBe(0); + expect(detectSaveRisks(mkDoc({ throwOnSig: true })).signatures).toBe(0); + }); + + it("combines both risks", () => { + const r = detectSaveRisks(mkDoc({ signatures: 1, formType: 2 })); + expect(describeSaveRisks(r)).toEqual([ + "This document carries a digital signature. Your changes are appended as a new revision, so the signed version stays verifiable, but the document will report as modified since it was signed.", + "Interactive XFA form data may be lost.", + ]); + }); + + it("flags an encrypted document and survives a missing API", () => { + expect(detectSaveRisks(mkDoc({ secHandlerRev: -1 })).encrypted).toBe(false); + const r = detectSaveRisks(mkDoc({ secHandlerRev: 3 })); + expect(r.encrypted).toBe(true); + expect(hasSaveRisks(r)).toBe(true); + expect(describeSaveRisks(r)).toContain( + "This PDF is encrypted; the saved copy will NOT be encrypted (password and access restrictions are removed).", + ); + expect(detectSaveRisks(mkDoc({ throwOnEncrypt: true })).encrypted).toBe( + false, + ); + }); +}); diff --git a/frontend/editor/src/core/tools/pdfTextEditor/__tests__/editorDirtyState.test.ts b/frontend/editor/src/core/tools/pdfTextEditor/__tests__/editorDirtyState.test.ts new file mode 100644 index 0000000000..27e1947abc --- /dev/null +++ b/frontend/editor/src/core/tools/pdfTextEditor/__tests__/editorDirtyState.test.ts @@ -0,0 +1,124 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { EditorStore } from "@app/tools/pdfTextEditor/store/EditorStore"; +import type { Command } from "@app/tools/pdfTextEditor/commands/Command"; +import type { EditorDocument } from "@app/tools/pdfTextEditor/model/EditorDocument"; + +function makeCmd(type = "test"): Command { + return { type, apply: vi.fn(), revert: vi.fn() } as unknown as Command; +} + +function makeKeyedCmd(key: string): Command { + return { + type: "keyed", + apply: vi.fn(), + revert: vi.fn(), + coalesceKey: () => key, + } as unknown as Command; +} + +function makeDoc(): EditorDocument { + return { + pageCount: 0, + loadedPages: () => [], + dispose: () => {}, + } as unknown as EditorDocument; +} + +async function makeStore(): Promise { + const store = new EditorStore(); + await store.setDocument(makeDoc()); + return store; +} + +describe("EditorStore dirty tracking", () => { + beforeEach(() => { + vi.useFakeTimers(); + }); + afterEach(() => { + vi.useRealTimers(); + }); + + it("a freshly loaded document is clean", async () => { + const store = await makeStore(); + expect(store.getState().dirty).toBe(false); + }); + + it("an edit dirties the document and saving clears it", async () => { + const store = await makeStore(); + store.dispatch(makeCmd("a")); + expect(store.getState().dirty).toBe(true); + store.markSaved(); + expect(store.getState().dirty).toBe(false); + }); + + it("undoing past the saved point reports dirty", async () => { + const store = await makeStore(); + store.dispatch(makeCmd("a")); + store.markSaved(); + store.undo(); + expect(store.getState().dirty).toBe(true); + }); + + it("a new edit after save then undo reports dirty", async () => { + const store = await makeStore(); + store.dispatch(makeCmd("a")); + store.markSaved(); + store.undo(); + store.dispatch(makeCmd("b")); + expect(store.getState().dirty).toBe(true); + }); + + it("undoing back to the saved point reports clean", async () => { + const store = await makeStore(); + store.dispatch(makeCmd("a")); + store.markSaved(); + store.dispatch(makeCmd("b")); + expect(store.getState().dirty).toBe(true); + store.undo(); + expect(store.getState().dirty).toBe(false); + }); + + it("redoing away from the saved point reports dirty", async () => { + const store = await makeStore(); + store.dispatch(makeCmd("a")); + store.markSaved(); + store.dispatch(makeCmd("b")); + store.undo(); + store.redo(); + expect(store.getState().dirty).toBe(true); + }); + + it("a coalescable edit after saving cannot rejoin the saved step", async () => { + const store = await makeStore(); + store.dispatch(makeKeyedCmd("run:1")); + store.dispatch(makeKeyedCmd("run:1")); + expect(store.history.size().undo).toBe(1); + store.markSaved(); + store.dispatch(makeKeyedCmd("run:1")); + expect(store.history.size().undo).toBe(2); + expect(store.getState().dirty).toBe(true); + }); + + it("undoing a post-save coalesced burst returns to the saved step", async () => { + const store = await makeStore(); + store.dispatch(makeKeyedCmd("run:1")); + store.markSaved(); + store.dispatch(makeKeyedCmd("run:1")); + store.dispatch(makeKeyedCmd("run:1")); + expect(store.getState().dirty).toBe(true); + store.undo(); + expect(store.getState().dirty).toBe(false); + }); + + it("resetAll returns to clean only when the base was the saved state", async () => { + const store = await makeStore(); + store.dispatch(makeCmd("a")); + store.resetAll(); + expect(store.getState().dirty).toBe(false); + + store.dispatch(makeCmd("b")); + store.markSaved(); + store.resetAll(); + expect(store.getState().dirty).toBe(true); + }); +}); diff --git a/frontend/editor/src/core/tools/pdfTextEditor/__tests__/embeddedFace.test.ts b/frontend/editor/src/core/tools/pdfTextEditor/__tests__/embeddedFace.test.ts new file mode 100644 index 0000000000..49d5593317 --- /dev/null +++ b/frontend/editor/src/core/tools/pdfTextEditor/__tests__/embeddedFace.test.ts @@ -0,0 +1,365 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"; +import { + embeddedFaceFamily, + isEmbeddedFaceReady, + onEmbeddedFaceLoaded, + registerEmbeddedFace, + resetEmbeddedFaces, +} from "@app/tools/pdfTextEditor/util/embeddedFace"; + +type PdfiumModule = Parameters[0]; + +const MAX_FACE_BYTES = 8 * 1024 * 1024; + +interface FaceRecord { + family: string; + size: number; + resolve: () => void; + reject: () => void; +} + +let created: FaceRecord[] = []; +let constructorThrows = false; +let addedFamilies: string[] = []; +let fontsAdd: ReturnType; +let fontsDelete: ReturnType; + +class FakeFontFace { + family: string; + rec: FaceRecord; + constructor(family: string, source: BufferSource) { + if (constructorThrows) throw new TypeError("malformed buffer"); + this.family = family; + this.rec = { + family, + size: (source as Uint8Array).byteLength, + resolve: () => {}, + reject: () => {}, + }; + created.push(this.rec); + } + load(): Promise { + return new Promise((res, rej) => { + this.rec.resolve = () => res(this); + this.rec.reject = () => rej(new Error("unsupported format")); + }); + } +} + +function faceFor(family: string): FaceRecord | undefined { + return created.find((f) => f.family === family); +} + +function makeModule( + data: Map, + heapBytes = 9 * 1024 * 1024, +): PdfiumModule { + const memory = { buffer: new ArrayBuffer(heapBytes) }; + let next = 8; // pointer 0 means "absent" to the code under test + let live = 0; + const view = () => new DataView(memory.buffer); + const fake = { + pdfium: { + wasmExports: { + memory, + malloc(n: number): number { + const ptr = next; + next += (n + 7) & ~7; + if (next > heapBytes) throw new Error("fake heap exhausted"); + live++; + return ptr; + }, + free(): void { + if (--live === 0) next = 8; + }, + }, + getValue(ptr: number): number { + return view().getInt32(ptr, true); + }, + }, + FPDFFont_GetFontData( + font: number, + buf: number, + len: number, + out: number, + ): boolean { + const bytes = data.get(font); + if (!bytes) return false; + if (buf && len >= bytes.length) { + new Uint8Array(memory.buffer).set(bytes, buf); + } + view().setInt32(out, bytes.length, true); + return true; + }, + }; + return fake as unknown as PdfiumModule; +} + +function fontBytes(sig: string | number[], size = 64): Uint8Array { + const bytes = new Uint8Array(size); + const head = + typeof sig === "string" ? [...sig].map((c) => c.charCodeAt(0)) : sig; + bytes.set(head.slice(0, size), 0); + return bytes; +} + +const TRUETYPE = [0x00, 0x01, 0x00, 0x00]; + +async function flush(): Promise { + for (let i = 0; i < 4; i++) await Promise.resolve(); +} + +beforeEach(() => { + created = []; + addedFamilies = []; + constructorThrows = false; + fontsAdd = vi.fn((face: FakeFontFace) => addedFamilies.push(face.family)); + fontsDelete = vi.fn(); + Object.defineProperty(document, "fonts", { + value: { add: fontsAdd, delete: fontsDelete }, + configurable: true, + writable: true, + }); + (globalThis as { FontFace?: unknown }).FontFace = FakeFontFace; + resetEmbeddedFaces(); +}); + +afterEach(() => { + resetEmbeddedFaces(); + delete (globalThis as { FontFace?: unknown }).FontFace; + Reflect.deleteProperty(document, "fonts"); +}); + +describe("registerEmbeddedFace format sniff", () => { + it("accepts every signature a browser can load", () => { + const data = new Map([ + [11, fontBytes(TRUETYPE)], + [12, fontBytes("true")], + [13, fontBytes("OTTO")], + [14, fontBytes("wOFF")], + [15, fontBytes("wOF2")], + ]); + const m = makeModule(data); + for (const ptr of data.keys()) registerEmbeddedFace(m, ptr); + expect(created.map((f) => f.family)).toEqual([ + embeddedFaceFamily(11), + embeddedFaceFamily(12), + embeddedFaceFamily(13), + embeddedFaceFamily(14), + embeddedFaceFamily(15), + ]); + }); + + it("skips formats FontFace refuses, before building a face", () => { + const data = new Map([ + [21, fontBytes("ttcf")], // TrueType collection + [22, fontBytes([0x01, 0x00, 0x04, 0x04])], // bare CFF + [23, fontBytes("%!PS")], // Type1 + [24, fontBytes(TRUETYPE, 3)], // too short to sniff + ]); + const m = makeModule(data); + for (const ptr of data.keys()) registerEmbeddedFace(m, ptr); + expect(created).toHaveLength(0); + }); + + it("ignores a null pointer and a font PDFium has no data for", () => { + const m = makeModule(new Map([[31, fontBytes(TRUETYPE)]])); + registerEmbeddedFace(m, 0); + registerEmbeddedFace(m, 32); + expect(created).toHaveLength(0); + }); + + it("tries a pointer once per document", () => { + const m = makeModule(new Map([[41, fontBytes(TRUETYPE)]])); + registerEmbeddedFace(m, 41); + registerEmbeddedFace(m, 41); + expect(created).toHaveLength(1); + }); + + it("does nothing when FontFace is unavailable", () => { + delete (globalThis as { FontFace?: unknown }).FontFace; + const m = makeModule(new Map([[51, fontBytes(TRUETYPE)]])); + expect(() => registerEmbeddedFace(m, 51)).not.toThrow(); + expect(created).toHaveLength(0); + }); +}); + +describe("embedded face byte budget", () => { + const big = fontBytes(TRUETYPE, MAX_FACE_BYTES); + + function moduleOf(ptrs: number[], bytes: Uint8Array): PdfiumModule { + return makeModule(new Map(ptrs.map((p) => [p, bytes]))); + } + + it("rejects a face whose reported size is beyond the per-face cap", () => { + const over = fontBytes(TRUETYPE, MAX_FACE_BYTES + 1); + registerEmbeddedFace(moduleOf([61], over), 61); + expect(created).toHaveLength(0); + }); + + it("frees the budget of a load that rejects", async () => { + const ptrs = [71, 72, 73, 74, 75, 76]; + const m = moduleOf([...ptrs, 77], big); + for (const ptr of ptrs) registerEmbeddedFace(m, ptr); + expect(created).toHaveLength(6); + for (const rec of created) rec.reject(); + await flush(); + + registerEmbeddedFace(m, 77); + expect(created).toHaveLength(7); + faceFor(embeddedFaceFamily(77))?.resolve(); + await flush(); + expect(isEmbeddedFaceReady(77)).toBe(true); + }); + + it("frees the budget when the FontFace constructor throws", () => { + constructorThrows = true; + const ptrs = [81, 82, 83, 84, 85, 86]; + const m = moduleOf([...ptrs, 87], big); + for (const ptr of ptrs) registerEmbeddedFace(m, ptr); + expect(created).toHaveLength(0); + + constructorThrows = false; + registerEmbeddedFace(m, 87); + expect(created).toHaveLength(1); + }); + + it("still skips a face once the budget is genuinely held", () => { + const ptrs = [91, 92, 93, 94, 95, 96]; + const m = moduleOf([...ptrs, 97], big); + for (const ptr of ptrs) registerEmbeddedFace(m, ptr); + registerEmbeddedFace(m, 97); + expect(created).toHaveLength(6); + }); + + it("frees the whole budget on reset", () => { + const ptrs = [101, 102, 103, 104, 105, 106]; + const m = moduleOf([...ptrs, 107], big); + for (const ptr of ptrs) registerEmbeddedFace(m, ptr); + resetEmbeddedFaces(); + registerEmbeddedFace(m, 107); + expect(created).toHaveLength(7); + }); +}); + +describe("resetEmbeddedFaces vs an in-flight load", () => { + it("drops a face that resolves after its document is gone", async () => { + const m = makeModule(new Map([[111, fontBytes(TRUETYPE)]])); + registerEmbeddedFace(m, 111); + const pending = faceFor(embeddedFaceFamily(111)); + + resetEmbeddedFaces(); + pending?.resolve(); + await flush(); + + expect(fontsAdd).not.toHaveBeenCalled(); + expect(isEmbeddedFaceReady(111)).toBe(false); + }); + + it("removes the faces it added and clears readiness", async () => { + const m = makeModule(new Map([[121, fontBytes(TRUETYPE)]])); + registerEmbeddedFace(m, 121); + faceFor(embeddedFaceFamily(121))?.resolve(); + await flush(); + expect(isEmbeddedFaceReady(121)).toBe(true); + + resetEmbeddedFaces(); + expect(fontsDelete).toHaveBeenCalledTimes(1); + expect(isEmbeddedFaceReady(121)).toBe(false); + }); + + it("re-registers a reused pointer for the new document", async () => { + const m = makeModule(new Map([[131, fontBytes(TRUETYPE)]])); + registerEmbeddedFace(m, 131); + resetEmbeddedFaces(); + + registerEmbeddedFace(m, 131); + expect(created).toHaveLength(2); + created[1].resolve(); + await flush(); + expect(isEmbeddedFaceReady(131)).toBe(true); + expect(addedFamilies).toEqual([embeddedFaceFamily(131)]); + }); +}); + +describe("embedded face load signal", () => { + it("reports readiness only once the face is in document.fonts", async () => { + const m = makeModule(new Map([[141, fontBytes(TRUETYPE)]])); + registerEmbeddedFace(m, 141); + expect(isEmbeddedFaceReady(141)).toBe(false); + + faceFor(embeddedFaceFamily(141))?.resolve(); + await flush(); + expect(isEmbeddedFaceReady(141)).toBe(true); + expect(fontsAdd).toHaveBeenCalledTimes(1); + }); + + it("stays unready when the load rejects", async () => { + const m = makeModule(new Map([[151, fontBytes(TRUETYPE)]])); + const listener = vi.fn(); + onEmbeddedFaceLoaded(listener); + registerEmbeddedFace(m, 151); + faceFor(embeddedFaceFamily(151))?.reject(); + await flush(); + expect(isEmbeddedFaceReady(151)).toBe(false); + expect(listener).not.toHaveBeenCalled(); + }); + + it("notifies subscribers once per successful load", async () => { + const listener = vi.fn(); + onEmbeddedFaceLoaded(listener); + const m = makeModule( + new Map([ + [161, fontBytes(TRUETYPE)], + [162, fontBytes("OTTO")], + ]), + ); + registerEmbeddedFace(m, 161); + registerEmbeddedFace(m, 162); + expect(listener).not.toHaveBeenCalled(); + + faceFor(embeddedFaceFamily(161))?.resolve(); + await flush(); + expect(listener).toHaveBeenCalledTimes(1); + faceFor(embeddedFaceFamily(162))?.resolve(); + await flush(); + expect(listener).toHaveBeenCalledTimes(2); + }); + + it("stops notifying after unsubscribe", async () => { + const listener = vi.fn(); + const off = onEmbeddedFaceLoaded(listener); + off(); + const m = makeModule(new Map([[171, fontBytes(TRUETYPE)]])); + registerEmbeddedFace(m, 171); + faceFor(embeddedFaceFamily(171))?.resolve(); + await flush(); + expect(listener).not.toHaveBeenCalled(); + }); + + it("keeps notifying a throwing subscriber's neighbours", async () => { + const bad = vi.fn(() => { + throw new Error("subscriber blew up"); + }); + const good = vi.fn(); + onEmbeddedFaceLoaded(bad); + onEmbeddedFaceLoaded(good); + const m = makeModule(new Map([[181, fontBytes(TRUETYPE)]])); + registerEmbeddedFace(m, 181); + faceFor(embeddedFaceFamily(181))?.resolve(); + await flush(); + expect(good).toHaveBeenCalledTimes(1); + }); + + it("keeps subscriptions across a document swap", async () => { + const listener = vi.fn(); + onEmbeddedFaceLoaded(listener); + resetEmbeddedFaces(); + + const m = makeModule(new Map([[191, fontBytes(TRUETYPE)]])); + registerEmbeddedFace(m, 191); + faceFor(embeddedFaceFamily(191))?.resolve(); + await flush(); + expect(listener).toHaveBeenCalledTimes(1); + }); +}); diff --git a/frontend/editor/src/core/tools/pdfTextEditor/__tests__/exactLayout.test.ts b/frontend/editor/src/core/tools/pdfTextEditor/__tests__/exactLayout.test.ts new file mode 100644 index 0000000000..cf8f6a9d2d --- /dev/null +++ b/frontend/editor/src/core/tools/pdfTextEditor/__tests__/exactLayout.test.ts @@ -0,0 +1,192 @@ +import { describe, expect, it } from "vitest"; +import { + buildExactLines, + type CharPositions, +} from "@app/tools/pdfTextEditor/util/exactLayout"; +import { TextRun } from "@app/tools/pdfTextEditor/model/TextRun"; + +/** Positions for `text` where every glyph advances by `advance` points. */ +function uniform(text: string, advance = 10): CharPositions { + const starts: number[] = []; + const ends: number[] = []; + let x = 0; + for (const ch of text) { + if (ch === "\n") { + starts.push(Number.NaN); + ends.push(Number.NaN); + x = 0; + continue; + } + starts.push(x); + ends.push(x + advance); + x += advance; + } + return { starts, ends }; +} + +describe("buildExactLines", () => { + it("splits a line into word and space boxes at the captured advances", () => { + const text = "ab cd"; + const lines = buildExactLines(text, uniform(text)); + expect(lines).toHaveLength(1); + expect(lines?.[0].left).toBe(0); + expect(lines?.[0].tokens).toEqual([ + { text: "ab", width: 20, space: false }, + { text: " ", width: 10, space: true }, + { text: "cd", width: 20, space: false }, + ]); + }); + + it("tiles boxes so each token starts at its own captured origin", () => { + const text = "one two three"; + const positions = uniform(text); + const lines = buildExactLines(text, positions); + let x = lines?.[0].left ?? 0; + let at = 0; + for (const token of lines?.[0].tokens ?? []) { + expect(x).toBeCloseTo(positions.starts[at], 6); + x += token.width; + at += token.text.length; + } + }); + + it("preserves an uneven justification gap rather than averaging it", () => { + // "a" then a wide gap then "b": the gap is the whole point of the capture. + const positions: CharPositions = { + starts: [0, 10, 60], + ends: [10, 60, 70], + }; + const lines = buildExactLines("a b", positions); + expect(lines?.[0].tokens.map((t) => t.width)).toEqual([10, 50, 10]); + }); + + it("gives each line of a paragraph its own left origin", () => { + const positions: CharPositions = { + starts: [0, 10, Number.NaN, 40, 50], + ends: [10, 20, Number.NaN, 50, 60], + }; + const lines = buildExactLines("ab\ncd", positions); + expect(lines).toHaveLength(2); + expect(lines?.[0].left).toBe(0); + expect(lines?.[1].left).toBe(40); + }); + + it("drops the engine-trimmed trailing spaces into a zero-width token", () => { + const positions: CharPositions = { + starts: [0, 10, Number.NaN], + ends: [10, 20, Number.NaN], + }; + const lines = buildExactLines("ab ", positions); + expect(lines?.[0].tokens).toEqual([ + { text: "ab", width: 20, space: false }, + { text: " ", width: 0, space: true }, + ]); + }); + + it("keeps every character of the text, so innerText still round-trips", () => { + const text = "hello there friend\nsecond line"; + const lines = buildExactLines(text, uniform(text)); + const rebuilt = (lines ?? []) + .map((line) => line.tokens.map((t) => t.text).join("")) + .join("\n"); + expect(rebuilt).toBe(text); + }); + + it("derives a synthesised space's width from the gap the engine left", () => { + // The grouper inserts this space between two separately-drawn words, so + // it backs no glyph and has no captured position of its own. + const positions: CharPositions = { + starts: [0, 10, Number.NaN, 45, 55], + ends: [10, 20, Number.NaN, 55, 65], + }; + const lines = buildExactLines("ab cd", positions); + expect(lines?.[0].tokens).toEqual([ + { text: "ab", width: 20, space: false }, + { text: " ", width: 25, space: true }, + { text: "cd", width: 20, space: false }, + ]); + }); + + it("still bails when a synthesised space has no word to measure against", () => { + const positions: CharPositions = { + starts: [0, 10, Number.NaN], + ends: [10, 20, Number.NaN], + }; + // Trailing spaces are trimmed, so put the unknown space mid-line with + // nothing usable after it. + expect( + buildExactLines("ab x", { + starts: [0, 10, Number.NaN, Number.NaN], + ends: [10, 20, Number.NaN, Number.NaN], + }), + ).toBeNull(); + expect(buildExactLines("ab ", positions)).not.toBeNull(); + }); + + it("returns null when a position is missing inside a word", () => { + const positions: CharPositions = { + starts: [0, Number.NaN, Number.NaN], + ends: [10, Number.NaN, Number.NaN], + }; + expect(buildExactLines("abc", positions)).toBeNull(); + }); + + it("returns null when the capture does not match the text length", () => { + expect( + buildExactLines("abc", { starts: [0, 10], ends: [10, 20] }), + ).toBeNull(); + }); + + it("returns null for empty text", () => { + expect(buildExactLines("", { starts: [], ends: [] })).toBeNull(); + }); + + it("returns null when positions run backwards", () => { + const positions: CharPositions = { starts: [50, 10], ends: [60, 20] }; + expect(buildExactLines("ab", positions)).toBeNull(); + }); +}); + +describe("capture validity", () => { + const base = { + id: "r1", + pageIndex: 0, + bounds: { x: 0, y: 0, width: 10, height: 10 }, + matrix: { a: 1, b: 0, c: 0, d: 1, e: 0, f: 0 }, + text: "ab", + fontId: "pdf:1:Helvetica", + fontSize: 12, + fill: { r: 0, g: 0, b: 0, a: 255 }, + fontSubset: false, + }; + + function measured(): TextRun { + const run = new TextRun({ ...base, pdfiumObjPtr: 1 }); + run.charStartsX = [0, 10]; + run.charEndsX = [10, 20]; + run.charPositionsKey = run.positionsKey(); + return run; + } + + it("publishes the capture while the run is unchanged", () => { + expect(measured().snapshot().charStartsX).toEqual([0, 10]); + }); + + it("drops the capture when the text changes", () => { + const run = measured(); + run.text = "abc"; + expect(run.snapshot().charStartsX).toBeUndefined(); + }); + + it("drops the capture when the size changes, which rescales every glyph", () => { + const run = measured(); + run.fontSize = 24; + expect(run.snapshot().charStartsX).toBeUndefined(); + }); + + it("drops the capture when the family changes", () => { + const run = measured(); + run.fontId = "base14:Times-Roman"; + expect(run.snapshot().charStartsX).toBeUndefined(); + }); +}); diff --git a/frontend/editor/src/core/tools/pdfTextEditor/__tests__/externalImageEdit.test.ts b/frontend/editor/src/core/tools/pdfTextEditor/__tests__/externalImageEdit.test.ts new file mode 100644 index 0000000000..c513458477 --- /dev/null +++ b/frontend/editor/src/core/tools/pdfTextEditor/__tests__/externalImageEdit.test.ts @@ -0,0 +1,326 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { deflateSync, inflateSync } from "node:zlib"; +import { + encodeRgbaAsPng, + isExternalImageEditSupported, + startExternalImageEdit, +} from "@app/tools/pdfTextEditor/util/externalImageEdit"; + +const POLL_MS = 500; + +const PIXELS = { + rgba: new Uint8Array([ + 1, 2, 3, 255, 4, 5, 6, 255, 7, 8, 9, 255, 10, 11, 12, 255, + ]), + width: 2, + height: 2, +}; + +interface FakeFile { + lastModified: number; + arrayBuffer(): Promise; +} + +function fakeHandle() { + const state = { + written: null as Uint8Array | null, + lastModified: 1000, + bytes: new Uint8Array([9, 9, 9]), + getFileCalls: 0, + hold: false, + release: null as null | (() => void), + failWith: null as unknown, + }; + const handle = { + name: "picture.png", + createWritable: async () => ({ + write: async (data: Uint8Array) => { + state.written = data; + }, + close: async () => undefined, + }), + getFile: async (): Promise => { + state.getFileCalls += 1; + if (state.hold) { + await new Promise((resolve) => { + state.release = resolve; + }); + } + if (state.failWith) throw state.failWith; + const bytes = state.bytes; + return { + lastModified: state.lastModified, + arrayBuffer: async () => + bytes.buffer.slice( + bytes.byteOffset, + bytes.byteOffset + bytes.byteLength, + ) as ArrayBuffer, + }; + }, + }; + return { state, handle }; +} + +function stubPicker(handle: unknown) { + const picker = vi.fn(async (_options?: { suggestedName?: string }) => handle); + vi.stubGlobal("showSaveFilePicker", picker); + return picker; +} + +function pngChunkBody(png: Uint8Array, type: string): Uint8Array | null { + const view = new DataView(png.buffer, png.byteOffset, png.byteLength); + let at = 8; + while (at + 8 <= png.length) { + const length = view.getUint32(at); + const name = String.fromCharCode(...png.subarray(at + 4, at + 8)); + if (name === type) return png.subarray(at + 8, at + 8 + length); + at += 12 + length; + } + return null; +} + +/** Expected PNG raw stream: one zero filter byte in front of every RGBA row. */ +function filteredScanlines(): Uint8Array { + return new Uint8Array([ + 0, 1, 2, 3, 255, 4, 5, 6, 255, 0, 7, 8, 9, 255, 10, 11, 12, 255, + ]); +} + +class FakeCompressionStream { + readable: { + getReader(): { read(): Promise<{ done: boolean; value?: Uint8Array }> }; + }; + writable: { + getWriter(): { + write(chunk: Uint8Array): Promise; + close(): Promise; + }; + }; + + constructor(_format: string) { + const parts: Uint8Array[] = []; + let resolveClosed = (): void => {}; + const closed = new Promise((resolve) => { + resolveClosed = resolve; + }); + let sent = false; + this.writable = { + getWriter: () => ({ + write: async (chunk: Uint8Array) => { + parts.push(chunk); + }, + close: async () => { + resolveClosed(); + }, + }), + }; + this.readable = { + getReader: () => ({ + read: async (): Promise<{ done: boolean; value?: Uint8Array }> => { + await closed; + if (sent) return { done: true }; + sent = true; + return { done: false, value: deflateSync(Buffer.concat(parts)) }; + }, + }), + }; + } +} + +describe("encodeRgbaAsPng", () => { + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it("writes a valid RGBA PNG using stored blocks when CompressionStream is absent", async () => { + vi.stubGlobal("CompressionStream", undefined); + const png = await encodeRgbaAsPng(PIXELS); + + expect(Array.from(png.subarray(0, 8))).toEqual([ + 0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, + ]); + const ihdr = pngChunkBody(png, "IHDR"); + expect(ihdr && Array.from(ihdr)).toEqual([ + 0, 0, 0, 2, 0, 0, 0, 2, 8, 6, 0, 0, 0, + ]); + const idat = pngChunkBody(png, "IDAT"); + expect(idat).not.toBeNull(); + expect( + Array.from(inflateSync(Buffer.from(idat ?? new Uint8Array()))), + ).toEqual(Array.from(filteredScanlines())); + }); + + it("compresses through CompressionStream when the browser has one", async () => { + vi.stubGlobal("CompressionStream", FakeCompressionStream); + const png = await encodeRgbaAsPng(PIXELS); + + const idat = pngChunkBody(png, "IDAT"); + expect( + Array.from(inflateSync(Buffer.from(idat ?? new Uint8Array()))), + ).toEqual(Array.from(filteredScanlines())); + }); +}); + +describe("startExternalImageEdit", () => { + beforeEach(() => { + vi.useFakeTimers(); + }); + + afterEach(() => { + vi.useRealTimers(); + vi.unstubAllGlobals(); + }); + + it("reports unsupported instead of throwing where showSaveFilePicker is missing", async () => { + vi.stubGlobal("showSaveFilePicker", undefined); + + expect(isExternalImageEditSupported()).toBe(false); + await expect( + startExternalImageEdit({ pixels: PIXELS, onChange: vi.fn() }), + ).resolves.toEqual({ status: "unsupported" }); + }); + + it("treats a cancelled picker as a normal outcome, not an error", async () => { + const abort = Object.assign(new Error("user cancelled"), { + name: "AbortError", + }); + vi.stubGlobal( + "showSaveFilePicker", + vi.fn(() => Promise.reject(abort)), + ); + + const outcome = await startExternalImageEdit({ + pixels: PIXELS, + onChange: vi.fn(), + }); + + expect(outcome).toEqual({ status: "cancelled" }); + }); + + it("writes the pixels out as a PNG under the suggested name", async () => { + const { state, handle } = fakeHandle(); + const picker = stubPicker(handle); + + const outcome = await startExternalImageEdit({ + pixels: PIXELS, + suggestedName: "logo.png", + onChange: vi.fn(), + }); + + expect(picker.mock.calls[0][0]).toMatchObject({ + suggestedName: "logo.png", + }); + expect(Array.from(state.written?.subarray(0, 4) ?? [])).toEqual([ + 0x89, 0x50, 0x4e, 0x47, + ]); + expect(outcome.status).toBe("watching"); + if (outcome.status === "watching") { + expect(outcome.watch.fileName).toBe("picture.png"); + outcome.watch.stop(); + } + }); + + it("reports the edited bytes exactly once per external save", async () => { + const { state, handle } = fakeHandle(); + stubPicker(handle); + const onChange = vi.fn(); + const outcome = await startExternalImageEdit({ + pixels: PIXELS, + pollIntervalMs: POLL_MS, + onChange, + }); + expect(outcome.status).toBe("watching"); + + await vi.advanceTimersByTimeAsync(POLL_MS * 2); + expect(onChange).not.toHaveBeenCalled(); + + state.lastModified = 2000; + state.bytes = new Uint8Array([1, 1]); + await vi.advanceTimersByTimeAsync(POLL_MS); + expect(onChange).toHaveBeenCalledTimes(1); + expect(Array.from(onChange.mock.calls[0][0] as Uint8Array)).toEqual([1, 1]); + + // Same mtime on later polls must not re-fire for the same edit. + await vi.advanceTimersByTimeAsync(POLL_MS * 3); + expect(onChange).toHaveBeenCalledTimes(1); + + state.lastModified = 3000; + state.bytes = new Uint8Array([2, 2, 2]); + await vi.advanceTimersByTimeAsync(POLL_MS); + expect(onChange).toHaveBeenCalledTimes(2); + expect(Array.from(onChange.mock.calls[1][0] as Uint8Array)).toEqual([ + 2, 2, 2, + ]); + + if (outcome.status === "watching") outcome.watch.stop(); + }); + + it("never overlaps polls when a read is slower than the interval", async () => { + const { state, handle } = fakeHandle(); + stubPicker(handle); + const onChange = vi.fn(); + const outcome = await startExternalImageEdit({ + pixels: PIXELS, + pollIntervalMs: POLL_MS, + onChange, + }); + + state.getFileCalls = 0; + state.hold = true; + state.lastModified = 2000; + await vi.advanceTimersByTimeAsync(POLL_MS * 4); + expect(state.getFileCalls).toBe(1); + + state.hold = false; + state.release?.(); + await vi.advanceTimersByTimeAsync(0); + expect(onChange).toHaveBeenCalledTimes(1); + + await vi.advanceTimersByTimeAsync(POLL_MS); + expect(state.getFileCalls).toBe(2); + + if (outcome.status === "watching") outcome.watch.stop(); + }); + + it("stops polling on a read error and reports it", async () => { + const { state, handle } = fakeHandle(); + stubPicker(handle); + const onError = vi.fn(); + await startExternalImageEdit({ + pixels: PIXELS, + pollIntervalMs: POLL_MS, + onChange: vi.fn(), + onError, + }); + + state.getFileCalls = 0; + state.failWith = new Error("file gone"); + await vi.advanceTimersByTimeAsync(POLL_MS); + expect(onError).toHaveBeenCalledTimes(1); + + await vi.advanceTimersByTimeAsync(POLL_MS * 5); + expect(state.getFileCalls).toBe(1); + }); + + it("stop() halts polling and is safe to call twice", async () => { + const { state, handle } = fakeHandle(); + stubPicker(handle); + const onChange = vi.fn(); + const outcome = await startExternalImageEdit({ + pixels: PIXELS, + pollIntervalMs: POLL_MS, + onChange, + }); + expect(outcome.status).toBe("watching"); + if (outcome.status !== "watching") return; + + state.getFileCalls = 0; + outcome.watch.stop(); + expect(() => outcome.watch.stop()).not.toThrow(); + + state.lastModified = 5000; + await vi.advanceTimersByTimeAsync(POLL_MS * 5); + expect(state.getFileCalls).toBe(0); + expect(onChange).not.toHaveBeenCalled(); + }); +}); diff --git a/frontend/editor/src/core/tools/pdfTextEditor/__tests__/fitText.test.ts b/frontend/editor/src/core/tools/pdfTextEditor/__tests__/fitText.test.ts new file mode 100644 index 0000000000..359c8b0662 --- /dev/null +++ b/frontend/editor/src/core/tools/pdfTextEditor/__tests__/fitText.test.ts @@ -0,0 +1,57 @@ +import { describe, expect, it } from "vitest"; +import { fitTextToWidth, NO_FIT } from "@app/tools/pdfTextEditor/util/fitText"; + +describe("fitTextToWidth", () => { + it("leaves text alone when it already matches", () => { + expect(fitTextToWidth("hello", 100, 100, 16)).toEqual(NO_FIT); + }); + + it("ignores sub-pixel differences", () => { + expect(fitTextToWidth("hello", 100.4, 100, 16)).toEqual(NO_FIT); + }); + + it("tightens with negative tracking when the text is too wide", () => { + // 10px over 10 chars = 1px per gap, well inside the tracking budget. + const fit = fitTextToWidth("abcdefghij", 110, 100, 16); + expect(fit.scaleX).toBe(1); + expect(fit.letterSpacing).toBeCloseTo(-1, 5); + }); + + it("loosens with positive tracking when the text is too narrow", () => { + const fit = fitTextToWidth("abcdefghij", 90, 100, 16); + expect(fit.scaleX).toBe(1); + expect(fit.letterSpacing).toBeCloseTo(1, 5); + }); + + it("scales instead of tracking when the correction is too large to hide", () => { + // 40px over 10 chars = 4px per gap on a 16px font = 0.25em, over budget. + const fit = fitTextToWidth("abcdefghij", 140, 100, 16); + expect(fit.letterSpacing).toBe(0); + expect(fit.scaleX).toBeCloseTo(100 / 140, 5); + }); + + it("scales a single character, which has no gaps to tighten", () => { + const fit = fitTextToWidth("W", 30, 20, 16); + expect(fit.letterSpacing).toBe(0); + expect(fit.scaleX).toBeCloseTo(20 / 30, 5); + }); + + it("gives up rather than squashing when the inputs disagree wildly", () => { + // A paragraph measured on one line against a single line's width. + expect(fitTextToWidth("a lot of text", 5000, 100, 14)).toEqual(NO_FIT); + expect(fitTextToWidth("x", 10, 100, 14)).toEqual(NO_FIT); + }); + + it("is inert for empty or degenerate input", () => { + expect(fitTextToWidth("", 100, 50, 16)).toEqual(NO_FIT); + expect(fitTextToWidth("hi", 0, 50, 16)).toEqual(NO_FIT); + expect(fitTextToWidth("hi", 50, 0, 16)).toEqual(NO_FIT); + expect(fitTextToWidth("hi", NaN, 50, 16)).toEqual(NO_FIT); + }); + + it("counts a surrogate pair as one character", () => { + // Two emoji = 2 characters, so 10px of overflow is 5px per gap. + const fit = fitTextToWidth("😀😀", 110, 100, 64); + expect(fit.letterSpacing).toBeCloseTo(-5, 5); + }); +}); diff --git a/frontend/editor/src/core/tools/pdfTextEditor/__tests__/fontCapability.test.ts b/frontend/editor/src/core/tools/pdfTextEditor/__tests__/fontCapability.test.ts new file mode 100644 index 0000000000..2b32f38db5 --- /dev/null +++ b/frontend/editor/src/core/tools/pdfTextEditor/__tests__/fontCapability.test.ts @@ -0,0 +1,169 @@ +import { describe, expect, it, beforeEach, vi } from "vitest"; +import { + canToggleItalic, + fallbackFamilyFor, + fallbackFontIdFor, + italicCapability, + resetDocumentFontMatchCache, + warmDocumentDeviceFonts, +} from "@app/tools/pdfTextEditor/util/fontCapability"; +import { + listLocalFonts, + loadLocalFontBytes, + resetLocalFontsCache, +} from "@app/tools/pdfTextEditor/util/localFonts"; +import type { LocalFont } from "@app/tools/pdfTextEditor/util/localFonts"; + +// The editor used to answer "make this italic" for ANY font by swapping the run +// wholesale to Helvetica-Oblique. For a document set in Calibri that is not +// italic, it is losing the typeface - and it happened silently, because the +// toolbar had no way to say the change was impossible. +// +// The same blind spot cost subset-embedded runs their face on every edit: +// helveticaVariantFor threw the family name away, so the device-font emit path +// (which needs the real family) could never fire, even with the face installed. + +const CALIBRI: LocalFont[] = [ + { + family: "Calibri", + fullName: "Calibri", + style: "Regular", + postscriptName: "Calibri", + }, + { + family: "Calibri", + fullName: "Calibri Italic", + style: "Italic", + postscriptName: "Calibri-Italic", + }, +]; + +/** An installed family with no italic cut at all. */ +const STENCIL: LocalFont[] = [ + { + family: "Stencil", + fullName: "Stencil", + style: "Regular", + postscriptName: "Stencil", + }, +]; + +function stubQueryLocalFonts(fonts: LocalFont[] | null): void { + const w = window as unknown as { queryLocalFonts?: unknown }; + if (fonts === null) { + delete w.queryLocalFonts; + return; + } + w.queryLocalFonts = vi.fn(async () => + fonts.map((font) => ({ + ...font, + blob: async () => ({ + arrayBuffer: async () => new Uint8Array([1]).buffer, + }), + })), + ); +} + +beforeEach(() => { + resetLocalFontsCache(); + resetDocumentFontMatchCache(); + stubQueryLocalFonts(null); +}); + +describe("italicCapability", () => { + it("flips a base-14 family in place", () => { + expect(italicCapability("base14:Helvetica", true, null)).toEqual({ + family: "Helvetica-Oblique", + source: "base14", + }); + expect(italicCapability("base14:Times-BoldItalic", false, null)).toEqual({ + family: "Times-Bold", + source: "base14", + }); + }); + + it("refuses an embedded family with no device fonts loaded", () => { + expect(italicCapability("pdf:4242:Calibri", true, null).family).toBeNull(); + }); + + it("refuses a subset family whose installed face has no italic cut", () => { + expect( + italicCapability("pdf:4242:Stencil", true, STENCIL).family, + ).toBeNull(); + }); + + it("uses the installed italic cut of the run's own family", () => { + expect(italicCapability("pdf:4242:Calibri", true, CALIBRI)).toEqual({ + family: "Calibri Italic", + source: "device", + }); + }); + + it("never substitutes a different typeface", () => { + // The whole point: Calibri does not become Helvetica just to look slanted. + const cap = italicCapability("pdf:4242:Calibri", true, STENCIL); + expect(cap.family).toBeNull(); + expect(cap.source).toBeNull(); + }); +}); + +describe("canToggleItalic", () => { + it("is false for an empty selection", () => { + expect(canToggleItalic([], CALIBRI)).toBe(false); + }); + + it("needs EVERY run to be capable", () => { + expect( + canToggleItalic(["base14:Helvetica", "base14:Times-Roman"], null), + ).toBe(true); + expect( + canToggleItalic(["base14:Helvetica", "pdf:1:Stencil"], STENCIL), + ).toBe(false); + }); +}); + +describe("fallbackFamilyFor", () => { + it("falls back to Helvetica when the family is not installed", () => { + expect(fallbackFamilyFor("pdf:4242:Calibri")).toBe("Helvetica"); + expect(fallbackFontIdFor("Helvetica")).toBe("base14:Helvetica"); + }); + + it("keeps a subset family whose real face is loaded", async () => { + stubQueryLocalFonts(CALIBRI); + await listLocalFonts(); + await warmDocumentDeviceFonts(["pdf:4242:Calibri"]); + + // Completing the subset now costs the document nothing: the edit re-emits + // in Calibri's real bytes rather than Helvetica. + expect(fallbackFamilyFor("pdf:4242:Calibri")).toBe("Calibri"); + expect(fallbackFontIdFor("Calibri")).toBe("device:Calibri"); + }); + + it("does not forget the face on the NEXT edit", async () => { + stubQueryLocalFonts(CALIBRI); + await listLocalFonts(); + await warmDocumentDeviceFonts(["pdf:4242:Calibri"]); + + // The id an edit leaves behind must still resolve to the same real family. + const nextId = fallbackFontIdFor(fallbackFamilyFor("pdf:4242:Calibri")); + expect(fallbackFamilyFor(nextId)).toBe("Calibri"); + }); +}); + +describe("warmDocumentDeviceFonts", () => { + it("matches only the document's own families, exactly", async () => { + stubQueryLocalFonts(CALIBRI); + await listLocalFonts(); + const matched = await warmDocumentDeviceFonts([ + "base14:Helvetica", + "pdf:1:Calibri", + "pdf:2:SomeFontNobodyHas", + ]); + expect(matched).toEqual(["Calibri"]); + expect(await loadLocalFontBytes("SomeFontNobodyHas")).toBeNull(); + }); + + it("is a no-op before the user loads their device fonts", async () => { + expect(await warmDocumentDeviceFonts(["pdf:1:Calibri"])).toEqual([]); + }); +}); diff --git a/frontend/editor/src/core/tools/pdfTextEditor/__tests__/fontFamily.test.ts b/frontend/editor/src/core/tools/pdfTextEditor/__tests__/fontFamily.test.ts new file mode 100644 index 0000000000..798ab1cb00 --- /dev/null +++ b/frontend/editor/src/core/tools/pdfTextEditor/__tests__/fontFamily.test.ts @@ -0,0 +1,66 @@ +import { describe, it, expect } from "vitest"; +import { + flipBold, + flipItalic, + nearestStandardFont, +} from "@app/tools/pdfTextEditor/util/fontFamily"; + +// The base-14 combined styles have EXACT PostScript spellings (Times uses +// Roman/Italic/BoldItalic; Helvetica/Courier use Oblique/BoldOblique). +describe("fontFamily base-14 style flips", () => { + it("bold-on preserves italic with the canonical combined name", () => { + expect(flipBold("Times-Italic", true)).toBe("Times-BoldItalic"); + expect(flipBold("Helvetica-Oblique", true)).toBe("Helvetica-BoldOblique"); + expect(flipBold("Courier-Oblique", true)).toBe("Courier-BoldOblique"); + }); + + it("italic-on preserves bold with the canonical combined name", () => { + expect(flipItalic("Times-Bold", true)).toBe("Times-BoldItalic"); + expect(flipItalic("Helvetica-Bold", true)).toBe("Helvetica-BoldOblique"); + // Courier italic was previously unrepresentable (returned null). + expect(flipItalic("Courier", true)).toBe("Courier-Oblique"); + expect(flipItalic("Courier-Bold", true)).toBe("Courier-BoldOblique"); + }); + + it("turning a style off returns the correct base / single-style name", () => { + expect(flipBold("Times-BoldItalic", false)).toBe("Times-Italic"); + expect(flipItalic("Times-BoldItalic", false)).toBe("Times-Bold"); + expect(flipBold("Helvetica-BoldOblique", false)).toBe("Helvetica-Oblique"); + expect(flipItalic("Helvetica-BoldOblique", false)).toBe("Helvetica-Bold"); + expect(flipBold("Helvetica-Bold", false)).toBe("Helvetica"); + expect(flipItalic("Times-Italic", false)).toBe("Times-Roman"); + }); + + it("returns null for non-base-14 families", () => { + expect(flipBold("LMRoman12", true)).toBeNull(); + expect(flipItalic("ABCDEF+CustomFont", true)).toBeNull(); + }); +}); + +/** An unknown family must be substituted, not dropped along with the text. */ +describe("nearestStandardFont", () => { + it("passes a standard font through untouched", () => { + expect(nearestStandardFont("Helvetica")).toBe("Helvetica"); + expect(nearestStandardFont("Times-BoldItalic")).toBe("Times-BoldItalic"); + expect(nearestStandardFont("Courier-Oblique")).toBe("Courier-Oblique"); + }); + + it("maps a device sans-serif family onto Helvetica", () => { + expect(nearestStandardFont("Segoe UI")).toBe("Helvetica"); + expect(nearestStandardFont("Arial")).toBe("Helvetica"); + }); + + it("recognises serif and monospace families by name", () => { + expect(nearestStandardFont("Georgia")).toBe("Times-Roman"); + expect(nearestStandardFont("Garamond")).toBe("Times-Roman"); + expect(nearestStandardFont("Consolas")).toBe("Courier"); + expect(nearestStandardFont("JetBrains Mono")).toBe("Courier"); + }); + + it("carries weight and slant across the substitution", () => { + expect(nearestStandardFont("Segoe UI Bold")).toBe("Helvetica-Bold"); + expect(nearestStandardFont("Georgia Bold Italic")).toBe("Times-BoldItalic"); + expect(nearestStandardFont("Consolas Italic")).toBe("Courier-Oblique"); + expect(nearestStandardFont("Inter SemiBold")).toBe("Helvetica-Bold"); + }); +}); diff --git a/frontend/editor/src/core/tools/pdfTextEditor/__tests__/guides.test.ts b/frontend/editor/src/core/tools/pdfTextEditor/__tests__/guides.test.ts new file mode 100644 index 0000000000..f649a377c0 --- /dev/null +++ b/frontend/editor/src/core/tools/pdfTextEditor/__tests__/guides.test.ts @@ -0,0 +1,419 @@ +import { describe, it, expect } from "vitest"; +import { + GuideStore, + MIN_LABEL_SPACING_PX, + MIN_TICK_SPACING_PX, + guideToLine, + lineToGuide, + rulerTicks, + snapToGuides, +} from "@app/tools/pdfTextEditor/util/guides"; +import type { Guide } from "@app/tools/pdfTextEditor/util/guides"; +import { DisplayTransform } from "@app/tools/pdfTextEditor/model/DisplayTransform"; + +// Pure geometry, pinned hard: ticks must stay readable at every zoom and +// snapping must be deterministic - a flickering snap target is worse than none. + +const ZOOMS = [ + 0.05, 0.1, 0.17, 0.25, 0.33, 0.5, 0.75, 1, 1.25, 1.5, 2, 2.5, 3, 4, 6, 8, 12, + 16, 24, 32, 48, 64, +]; +const LENGTHS = [595.276, 841.89, 612, 792, 200, 1000.5, 2000]; + +function mkGuide(id: string, position: number): Guide { + return { id, axis: "x", position }; +} + +/** True when `step` is a 1/2/5 x 10^n ladder value. */ +function isLadderStep(step: number): boolean { + const exponent = Math.floor(Math.log10(step) + 1e-9); + const mantissa = step / Math.pow(10, exponent); + return [1, 2, 5].some((m) => Math.abs(mantissa - m) < 1e-6); +} + +function isMultiple(value: number, step: number): boolean { + const ratio = value / step; + return Math.abs(ratio - Math.round(ratio)) < 1e-6; +} + +/** Tightest on-screen gap between neighbouring marks, Infinity when under two. */ +function minGapPx(marks: Array<{ position: number }>, scale: number): number { + let min = Number.POSITIVE_INFINITY; + for (let i = 1; i < marks.length; i += 1) { + min = Math.min(min, (marks[i].position - marks[i - 1].position) * scale); + } + return min; +} + +describe("rulerTicks", () => { + it("returns nothing for degenerate lengths or scales", () => { + for (const [length, scale] of [ + [0, 1], + [-10, 1], + [595, 0], + [595, -1], + [Number.NaN, 1], + [595, Number.NaN], + [Number.POSITIVE_INFINITY, 1], + [595, Number.POSITIVE_INFINITY], + ]) { + expect(rulerTicks(length, scale)).toEqual({ + minorStep: 0, + majorStep: 0, + ticks: [], + }); + } + }); + + it("never crowds ticks or labels below the readable pixel thresholds", () => { + for (const scale of ZOOMS) { + for (const length of LENGTHS) { + const { minorStep, majorStep, ticks } = rulerTicks(length, scale); + const where = `length=${length} scale=${scale}`; + expect(minorStep * scale, where).toBeGreaterThanOrEqual( + MIN_TICK_SPACING_PX, + ); + expect(majorStep * scale, where).toBeGreaterThanOrEqual( + MIN_LABEL_SPACING_PX, + ); + // Measured on screen, not inferred from the step. + expect(minGapPx(ticks, scale), where).toBeGreaterThanOrEqual( + MIN_TICK_SPACING_PX - 1e-9, + ); + expect( + minGapPx( + ticks.filter((t) => t.label !== null), + scale, + ), + where, + ).toBeGreaterThanOrEqual(MIN_LABEL_SPACING_PX - 1e-9); + } + } + }); + + it("keeps both steps on the 1/2/5 ladder with major a multiple of minor", () => { + for (const scale of ZOOMS) { + for (const length of LENGTHS) { + const { minorStep, majorStep } = rulerTicks(length, scale); + const where = `length=${length} scale=${scale}`; + expect(isLadderStep(minorStep), `${where} minor=${minorStep}`).toBe( + true, + ); + expect(isLadderStep(majorStep), `${where} major=${majorStep}`).toBe( + true, + ); + const ratio = majorStep / minorStep; + expect(Math.abs(ratio - Math.round(ratio)), where).toBeLessThan(1e-6); + expect(ratio, where).toBeGreaterThan(1); + } + } + }); + + it("labelled ticks are a strict subset sitting on round major positions", () => { + for (const scale of ZOOMS) { + for (const length of LENGTHS) { + const { majorStep, ticks } = rulerTicks(length, scale); + const where = `length=${length} scale=${scale}`; + const labelled = ticks.filter((t) => t.label !== null); + expect(labelled.length, where).toBeGreaterThan(0); + expect(labelled.length, where).toBeLessThan(ticks.length); + const offRound = labelled.filter( + (t) => !isMultiple(t.position, majorStep), + ); + expect( + offRound.map((t) => t.position), + where, + ).toEqual([]); + // The label reads the position it sits on, not an index. + const misread = labelled.filter( + (t) => Math.abs(Number(t.label) - t.position) > 1e-6, + ); + expect( + misread.map((t) => t.label), + where, + ).toEqual([]); + // `major` and `label` never disagree. + const disagree = ticks.filter((t) => t.major !== (t.label !== null)); + expect( + disagree.map((t) => t.position), + where, + ).toEqual([]); + } + } + }); + + it("covers the page from 0 to within one step of its length, strictly increasing", () => { + for (const scale of ZOOMS) { + for (const length of LENGTHS) { + const { minorStep, ticks } = rulerTicks(length, scale); + const where = `length=${length} scale=${scale}`; + expect(ticks[0].position, where).toBe(0); + expect(ticks[0].major, where).toBe(true); + const last = ticks[ticks.length - 1]; + expect(last.position, where).toBeLessThanOrEqual(length + 1e-9); + expect(length - last.position, where).toBeLessThan(minorStep); + expect(minGapPx(ticks, 1), where).toBeGreaterThan(0); + } + } + }); + + it("pins the interval at the zoom levels the editor actually uses", () => { + const cases: Array<[number, number, number]> = [ + [0.25, 50, 200], + [0.5, 20, 100], + [1, 10, 50], + [1.5, 5, 50], + [2, 5, 50], + [4, 2, 20], + ]; + for (const [scale, minorStep, majorStep] of cases) { + const ticks = rulerTicks(595.276, scale); + expect([scale, ticks.minorStep, ticks.majorStep]).toEqual([ + scale, + minorStep, + majorStep, + ]); + } + }); + + it("adds decimals to labels only when the major step is sub-point", () => { + expect(rulerTicks(600, 1).ticks[0].label).toBe("0"); + const fine = rulerTicks(20, 200); + expect(fine.majorStep).toBeLessThan(1); + const labels = fine.ticks + .filter((t) => t.label !== null) + .slice(0, 3) + .map((t) => t.label); + expect(labels.every((l) => (l ?? "").includes("."))).toBe(true); + }); + + it("stays bounded on a huge page at extreme zoom", () => { + const { ticks, minorStep } = rulerTicks(20000, 100); + expect(ticks.length).toBeLessThanOrEqual(4001); + // Widening the step, not truncating: the last tick still reaches the end. + expect(20000 - ticks[ticks.length - 1].position).toBeLessThan(minorStep); + }); +}); + +describe("snapToGuides", () => { + it("returns the value untouched when there are no guides", () => { + expect(snapToGuides(120.5, [], 5)).toEqual({ value: 120.5, guide: null }); + }); + + it("snaps inside the tolerance and leaves the value alone outside it", () => { + const guides = [mkGuide("a", 100)]; + expect(snapToGuides(103, guides, 5)).toEqual({ + value: 100, + guide: guides[0], + }); + expect(snapToGuides(97, guides, 5)).toEqual({ + value: 100, + guide: guides[0], + }); + expect(snapToGuides(106, guides, 5)).toEqual({ value: 106, guide: null }); + expect(snapToGuides(94, guides, 5)).toEqual({ value: 94, guide: null }); + }); + + it("treats the tolerance as inclusive", () => { + const guides = [mkGuide("a", 100)]; + expect(snapToGuides(105, guides, 5).guide).toBe(guides[0]); + expect(snapToGuides(105.000001, guides, 5).guide).toBeNull(); + }); + + it("picks the nearest guide, not the first in range", () => { + const guides = [mkGuide("a", 100), mkGuide("b", 108), mkGuide("c", 130)]; + expect(snapToGuides(107, guides, 10).guide?.id).toBe("b"); + expect(snapToGuides(102, guides, 10).guide?.id).toBe("a"); + }); + + it("breaks an exact tie on the lower id whatever the array order", () => { + const low = mkGuide("guide-000001", 90); + const high = mkGuide("guide-000002", 110); + expect(snapToGuides(100, [low, high], 20).guide?.id).toBe("guide-000001"); + expect(snapToGuides(100, [high, low], 20).guide?.id).toBe("guide-000001"); + }); + + it("with a zero tolerance only an exact hit snaps", () => { + const guides = [mkGuide("a", 100)]; + expect(snapToGuides(100, guides, 0).guide).toBe(guides[0]); + expect(snapToGuides(100.0001, guides, 0).guide).toBeNull(); + }); + + it("refuses to snap on a negative or non-finite tolerance", () => { + const guides = [mkGuide("a", 100)]; + expect(snapToGuides(100, guides, -1)).toEqual({ value: 100, guide: null }); + expect(snapToGuides(100, guides, Number.NaN).guide).toBeNull(); + }); + + it("ignores non-finite guide positions and values", () => { + const guides = [mkGuide("a", Number.NaN), mkGuide("b", 100)]; + expect(snapToGuides(101, guides, 5).guide?.id).toBe("b"); + const nan = snapToGuides(Number.NaN, guides, 5); + expect(Number.isNaN(nan.value)).toBe(true); + expect(nan.guide).toBeNull(); + }); +}); + +describe("guideToLine / lineToGuide", () => { + const CROP = { cl: 36, cb: 72, cw: 540, ch: 720 }; + + function mk(rotate: number): DisplayTransform { + const { cl, cb, cw, ch } = CROP; + const dw = rotate % 2 === 0 ? cw : ch; + const dh = rotate % 2 === 0 ? ch : cw; + return DisplayTransform.fromCropAndRotate(cl, cb, cw, ch, rotate, dw, dh); + } + + it("maps axes straight through on an identity page", () => { + const t = DisplayTransform.identity(600, 800); + expect(guideToLine({ axis: "x", position: 120 }, t)).toEqual({ + orientation: "vertical", + position: 120, + }); + expect(guideToLine({ axis: "y", position: 300 }, t)).toEqual({ + orientation: "horizontal", + position: 300, + }); + expect(lineToGuide({ orientation: "vertical", position: 120 }, t)).toEqual({ + axis: "x", + position: 120, + }); + }); + + it("shifts by the CropBox origin", () => { + const t = mk(0); + expect(guideToLine({ axis: "x", position: CROP.cl }, t).position).toBe(0); + expect(lineToGuide({ orientation: "horizontal", position: 0 }, t)).toEqual({ + axis: "y", + position: CROP.cb, + }); + }); + + it("swaps the drawn orientation on quarter-turned pages", () => { + for (const rotate of [1, 3]) { + const t = mk(rotate); + expect(guideToLine({ axis: "x", position: 100 }, t).orientation).toBe( + "horizontal", + ); + expect(guideToLine({ axis: "y", position: 100 }, t).orientation).toBe( + "vertical", + ); + } + for (const rotate of [0, 2]) { + const t = mk(rotate); + expect(guideToLine({ axis: "x", position: 100 }, t).orientation).toBe( + "vertical", + ); + } + }); + + it("round-trips for every rotation", () => { + for (const rotate of [0, 1, 2, 3]) { + const t = mk(rotate); + for (const seed of [ + { axis: "x" as const, position: 100 }, + { axis: "y" as const, position: 400.25 }, + ]) { + const back = lineToGuide(guideToLine(seed, t), t); + expect(back.axis, `rotate=${rotate}`).toBe(seed.axis); + expect(back.position, `rotate=${rotate}`).toBeCloseTo(seed.position, 6); + } + } + }); +}); + +describe("GuideStore", () => { + it("adds guides per page with ids that sort in creation order", () => { + const store = new GuideStore(); + const ids: string[] = []; + for (let i = 0; i < 12; i += 1) { + const guide = store.add(0, "x", i * 10); + expect(guide).not.toBeNull(); + if (guide) ids.push(guide.id); + } + expect(ids).toEqual([...ids].sort()); + expect(store.get(0)).toHaveLength(12); + expect(store.get(1)).toEqual([]); + }); + + it("rejects a non-finite position", () => { + const store = new GuideStore(); + expect(store.add(0, "x", Number.NaN)).toBeNull(); + expect(store.get(0)).toEqual([]); + }); + + it("replaces the array instead of mutating it on every change", () => { + const store = new GuideStore(); + const guide = store.add(0, "y", 50); + const before = store.get(0); + store.move(0, guide?.id ?? "", 80); + const after = store.get(0); + expect(after).not.toBe(before); + expect(before[0].position).toBe(50); + expect(after[0].position).toBe(80); + }); + + it("notifies on add / move / remove / clear but not on no-ops", () => { + const store = new GuideStore(); + const seen: Array<[number, number]> = []; + store.subscribe((pageIndex, guides) => + seen.push([pageIndex, guides.length]), + ); + const guide = store.add(2, "x", 10); + const id = guide?.id ?? ""; + store.move(2, id, 10); // same position + store.move(2, "nope", 40); // unknown id + store.remove(2, "nope"); // unknown id + store.clear(3); // page with no guides + store.move(2, id, 40); + store.remove(2, id); + store.clear(2); // already empty + expect(seen).toEqual([ + [2, 1], + [2, 1], + [2, 0], + ]); + }); + + it("clears one page or every page", () => { + const store = new GuideStore(); + store.add(0, "x", 10); + store.add(1, "y", 20); + store.clear(0); + expect(store.get(0)).toEqual([]); + expect(store.get(1)).toHaveLength(1); + store.add(0, "x", 30); + const pages: number[] = []; + store.subscribe((pageIndex) => pages.push(pageIndex)); + store.clear(); + expect(pages.sort()).toEqual([0, 1]); + expect(store.get(0)).toEqual([]); + expect(store.get(1)).toEqual([]); + }); + + it("unsubscribes cleanly", () => { + const store = new GuideStore(); + let calls = 0; + const off = store.subscribe(() => { + calls += 1; + }); + store.add(0, "x", 10); + off(); + store.add(0, "x", 20); + expect(calls).toBe(1); + }); + + it("keeps notifying when one listener throws or unsubscribes another", () => { + const store = new GuideStore(); + const calls: string[] = []; + store.subscribe(() => { + calls.push("first"); + off(); + throw new Error("boom"); + }); + const off = store.subscribe(() => calls.push("second")); + store.subscribe(() => calls.push("third")); + expect(() => store.add(0, "x", 10)).not.toThrow(); + expect(calls).toEqual(["first", "second", "third"]); + }); +}); diff --git a/frontend/editor/src/core/tools/pdfTextEditor/__tests__/helveticaVariant.test.ts b/frontend/editor/src/core/tools/pdfTextEditor/__tests__/helveticaVariant.test.ts new file mode 100644 index 0000000000..a109f5d30d --- /dev/null +++ b/frontend/editor/src/core/tools/pdfTextEditor/__tests__/helveticaVariant.test.ts @@ -0,0 +1,50 @@ +import { describe, it, expect } from "vitest"; +import { helveticaVariantFor } from "@app/tools/pdfTextEditor/util/helveticaVariant"; + +// The base-14 fallback used to map EVERY source font to a Helvetica variant. +describe("helveticaVariantFor", () => { + it("keeps sans-serif sources on Helvetica with canonical styles", () => { + expect(helveticaVariantFor("ABCDEF+Arial")).toBe("Helvetica"); + expect(helveticaVariantFor("Arial-BoldMT")).toBe("Helvetica-Bold"); + expect(helveticaVariantFor("Verdana-Italic")).toBe("Helvetica-Oblique"); + expect(helveticaVariantFor("Helvetica-BoldOblique")).toBe( + "Helvetica-BoldOblique", + ); + }); + + it("maps serif sources (incl. LaTeX Computer Modern) to Times", () => { + expect(helveticaVariantFor("ABCDEF+LMRoman12-Regular")).toBe("Times-Roman"); + expect(helveticaVariantFor("Times New Roman")).toBe("Times-Roman"); + expect(helveticaVariantFor("CMR10")).toBe("Times-Roman"); + expect(helveticaVariantFor("Georgia-BoldItalic")).toBe("Times-BoldItalic"); + expect(helveticaVariantFor("Garamond-Italic")).toBe("Times-Italic"); + expect(helveticaVariantFor("MinionPro-Bold")).toBe("Times-Bold"); + }); + + it("maps monospace sources to Courier", () => { + expect(helveticaVariantFor("Consolas")).toBe("Courier"); + expect(helveticaVariantFor("ABCDEF+CourierNew")).toBe("Courier"); + expect(helveticaVariantFor("DejaVuSansMono-Bold")).toBe("Courier-Bold"); + expect(helveticaVariantFor("MonoFont-Oblique")).toBe("Courier-Oblique"); + expect(helveticaVariantFor("SomethingMono-BoldItalic")).toBe( + "Courier-BoldOblique", + ); + }); + + it("monospace classification wins over an incidental serif keyword", () => { + // "Courier" is monospace even though it could read as a serif face. + expect(helveticaVariantFor("CourierBold")).toBe("Courier-Bold"); + }); +}); + +describe("device fonts survive an edit", () => { + it("keeps the embedded family instead of mapping it to base-14", () => { + expect(helveticaVariantFor("device:Segoe UI")).toBe("Segoe UI"); + expect(helveticaVariantFor("device:Georgia Bold")).toBe("Georgia Bold"); + }); + + it("still maps a non-device id by its style class", () => { + expect(helveticaVariantFor("pdf:12:ArialBold")).toBe("Helvetica-Bold"); + expect(helveticaVariantFor("base14:Times-Roman")).toBe("Times-Roman"); + }); +}); diff --git a/frontend/editor/src/core/tools/pdfTextEditor/__tests__/historyFailure.test.ts b/frontend/editor/src/core/tools/pdfTextEditor/__tests__/historyFailure.test.ts new file mode 100644 index 0000000000..20cb12ec5e --- /dev/null +++ b/frontend/editor/src/core/tools/pdfTextEditor/__tests__/historyFailure.test.ts @@ -0,0 +1,61 @@ +import { describe, expect, it } from "vitest"; +import { + HistoryStack, + HistoryStepError, +} from "@app/tools/pdfTextEditor/store/HistoryStack"; +import type { Command } from "@app/tools/pdfTextEditor/commands/Command"; +import type { EditorDocument } from "@app/tools/pdfTextEditor/model/EditorDocument"; + +const doc = {} as EditorDocument; + +function cmd(opts: { failRevert?: boolean; failApply?: boolean }): Command { + return { + type: "test", + apply: () => { + if (opts.failApply) throw new Error("apply blew up"); + }, + revert: () => { + if (opts.failRevert) throw new Error("revert blew up"); + }, + } as unknown as Command; +} + +describe("HistoryStack failure handling", () => { + it("surfaces a failed revert instead of leaking the raw error", () => { + const h = new HistoryStack(); + h.execute(cmd({ failRevert: true }), doc); + expect(() => h.undo(doc)).toThrow(HistoryStepError); + }); + + it("does not put a failed command back on the redo stack", () => { + const h = new HistoryStack(); + h.execute(cmd({ failRevert: true }), doc); + try { + h.undo(doc); + } catch { + /* expected */ + } + // Neither stack may claim the command: the document state is unknown. + expect(h.size()).toEqual({ undo: 0, redo: 0 }); + }); + + it("surfaces a failed redo the same way", () => { + const h = new HistoryStack(); + const c = cmd({}); + h.execute(c, doc); + h.undo(doc); + // Make the redo throw only now, after the command is on the redo stack. + (c as unknown as { apply: () => void }).apply = () => { + throw new Error("apply blew up"); + }; + expect(() => h.redo(doc)).toThrow(HistoryStepError); + expect(h.size()).toEqual({ undo: 0, redo: 0 }); + }); + + it("still reverts normally when nothing throws", () => { + const h = new HistoryStack(); + h.execute(cmd({}), doc); + expect(h.undo(doc)).not.toBeNull(); + expect(h.size()).toEqual({ undo: 0, redo: 1 }); + }); +}); diff --git a/frontend/editor/src/core/tools/pdfTextEditor/__tests__/lineLayout.test.ts b/frontend/editor/src/core/tools/pdfTextEditor/__tests__/lineLayout.test.ts new file mode 100644 index 0000000000..b3d1c3f4c2 --- /dev/null +++ b/frontend/editor/src/core/tools/pdfTextEditor/__tests__/lineLayout.test.ts @@ -0,0 +1,106 @@ +import { describe, expect, it } from "vitest"; +import { + fitTokenAdvance, + NO_TOKEN_FIT, + stackLineBoxes, +} from "@app/tools/pdfTextEditor/util/lineLayout"; + +function renderedAdvance( + charCount: number, + naturalPx: number, + fit: { letterSpacingPx: number; marginRightPx: number }, +): number { + return naturalPx + charCount * fit.letterSpacingPx + fit.marginRightPx; +} + +describe("fitTokenAdvance", () => { + it("leaves a token alone when it already measures right", () => { + expect(fitTokenAdvance(5, 80, 80, 16)).toEqual(NO_TOKEN_FIT); + }); + + it("ignores differences too small to see", () => { + expect(fitTokenAdvance(5, 80, 80.005, 16)).toEqual(NO_TOKEN_FIT); + }); + + it("tightens a token the browser laid out too wide", () => { + const fit = fitTokenAdvance(3, 26.813, 23.422, 19); + expect(fit.letterSpacingPx).toBeLessThan(0); + expect(renderedAdvance(3, 26.813, fit)).toBeCloseTo(23.422, 6); + }); + + it("widens a token the browser laid out too narrow", () => { + const fit = fitTokenAdvance(10, 70, 76.7, 19); + expect(fit.letterSpacingPx).toBeGreaterThan(0); + expect(renderedAdvance(10, 70, fit)).toBeCloseTo(76.7, 6); + }); + + it("spreads the correction between glyphs, not after the last one", () => { + const fit = fitTokenAdvance(3, 30, 24, 20); + expect(fit.letterSpacingPx).toBeCloseTo(-3, 6); + expect(fit.marginRightPx).toBeCloseTo(3, 6); + }); + + it("puts the whole correction in the margin for a single glyph", () => { + const fit = fitTokenAdvance(1, 10, 14, 16); + expect(fit.letterSpacingPx).toBe(0); + expect(fit.marginRightPx).toBeCloseTo(4, 6); + expect(renderedAdvance(1, 10, fit)).toBeCloseTo(14, 6); + }); + + it("caps tracking but still lands on the exact advance", () => { + const fit = fitTokenAdvance(4, 20, 200, 16); + expect(fit.letterSpacingPx).toBeCloseTo(0.25 * 16, 6); + expect(renderedAdvance(4, 20, fit)).toBeCloseTo(200, 6); + }); + + it("refuses nonsense inputs rather than emitting NaN", () => { + expect(fitTokenAdvance(0, 10, 20, 16)).toEqual(NO_TOKEN_FIT); + expect(fitTokenAdvance(3, Number.NaN, 20, 16)).toEqual(NO_TOKEN_FIT); + expect(fitTokenAdvance(3, 10, Number.POSITIVE_INFINITY, 16)).toEqual( + NO_TOKEN_FIT, + ); + expect(fitTokenAdvance(3, -1, 20, 16)).toEqual(NO_TOKEN_FIT); + }); +}); + +describe("stackLineBoxes", () => { + it("puts the first baseline where the caller asked", () => { + const stack = stackLineBoxes([100, 120, 140], 16, 12); + expect(stack?.topPx).toBe(88); + expect(stack?.marginTopsPx[0]).toBe(0); + }); + + it("keeps uneven leading instead of averaging it", () => { + const stack = stackLineBoxes([100, 120, 143], 16, 12); + expect(stack?.marginTopsPx).toEqual([0, 4, 7]); + }); + + it("stacks back onto the exact baselines it was given", () => { + const baselines = [100, 120, 143, 161.5]; + const stack = stackLineBoxes(baselines, 16, 12); + let y = stack!.topPx; + baselines.forEach((baseline, i) => { + y += stack!.marginTopsPx[i]; + expect(y + 12).toBeCloseTo(baseline, 6); + y += 16; + }); + }); + + it("allows a negative gap when lines overlap", () => { + const stack = stackLineBoxes([100, 110], 16, 12); + expect(stack?.marginTopsPx[1]).toBe(-6); + }); + + it("rejects input it cannot place", () => { + expect(stackLineBoxes([], 16, 12)).toBeNull(); + expect(stackLineBoxes([100, Number.NaN], 16, 12)).toBeNull(); + expect(stackLineBoxes([100], 0, 12)).toBeNull(); + expect(stackLineBoxes([100], 16, Number.NaN)).toBeNull(); + }); + + it("collapses to no gaps when the leading really is even", () => { + expect(stackLineBoxes([100, 116, 132], 16, 12)?.marginTopsPx).toEqual([ + 0, 0, 0, + ]); + }); +}); diff --git a/frontend/editor/src/core/tools/pdfTextEditor/__tests__/localFonts.test.ts b/frontend/editor/src/core/tools/pdfTextEditor/__tests__/localFonts.test.ts new file mode 100644 index 0000000000..cbbe18f550 --- /dev/null +++ b/frontend/editor/src/core/tools/pdfTextEditor/__tests__/localFonts.test.ts @@ -0,0 +1,202 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"; +import { + groupByFamily, + isLocalFontAccessSupported, + listLocalFonts, + resetLocalFontsCache, +} from "@app/tools/pdfTextEditor/util/localFonts"; +import type { LocalFont } from "@app/tools/pdfTextEditor/util/localFonts"; + +type QueryStub = () => Promise; + +function setQuery(stub: QueryStub | null): void { + const w = window as unknown as { queryLocalFonts?: QueryStub }; + if (stub) w.queryLocalFonts = stub; + else delete w.queryLocalFonts; +} + +function face( + family: string, + style: string, + postscriptName: string, +): Record { + return { family, style, postscriptName, fullName: `${family} ${style}` }; +} + +function mkFont(family: string, style: string): LocalFont { + return { + family, + style, + fullName: `${family} ${style}`, + postscriptName: `${family}-${style}`, + }; +} + +beforeEach(() => { + resetLocalFontsCache(); + setQuery(null); +}); + +afterEach(() => { + resetLocalFontsCache(); + setQuery(null); +}); + +describe("isLocalFontAccessSupported", () => { + it("is false when the API is missing", () => { + expect(isLocalFontAccessSupported()).toBe(false); + }); + + it("is true when the API exists, without calling it", () => { + const query = vi.fn().mockResolvedValue([]); + setQuery(query); + expect(isLocalFontAccessSupported()).toBe(true); + expect(query).not.toHaveBeenCalled(); + }); +}); + +describe("listLocalFonts", () => { + it("returns null in a browser without the API", async () => { + await expect(listLocalFonts()).resolves.toBeNull(); + }); + + it("maps the faces the API returns", async () => { + setQuery( + vi + .fn() + .mockResolvedValue([ + face("Inter", "Regular", "Inter-Regular"), + face("Inter", "Bold", "Inter-Bold"), + ]), + ); + const fonts = await listLocalFonts(); + expect(fonts).toEqual([ + { + family: "Inter", + style: "Regular", + postscriptName: "Inter-Regular", + fullName: "Inter Regular", + }, + { + family: "Inter", + style: "Bold", + postscriptName: "Inter-Bold", + fullName: "Inter Bold", + }, + ]); + }); + + it("drops entries without a usable family", async () => { + setQuery( + vi + .fn() + .mockResolvedValue([ + face("Inter", "Regular", "Inter-Regular"), + { family: 42, style: "Regular" }, + { style: "Bold" }, + null, + ]), + ); + const fonts = await listLocalFonts(); + expect(fonts).toHaveLength(1); + expect(fonts?.[0]?.family).toBe("Inter"); + }); + + it("returns null when permission is denied", async () => { + for (const name of ["SecurityError", "NotAllowedError"]) { + resetLocalFontsCache(); + const error = new Error("denied"); + error.name = name; + setQuery(vi.fn().mockRejectedValue(error)); + await expect(listLocalFonts()).resolves.toBeNull(); + } + }); + + it("returns null when the API throws unexpectedly", async () => { + setQuery( + vi.fn().mockImplementation(() => { + throw new TypeError("boom"); + }), + ); + await expect(listLocalFonts()).resolves.toBeNull(); + }); + + it("returns null when the API resolves to a non-array", async () => { + setQuery( + vi.fn().mockResolvedValue(undefined as unknown as unknown[]), + ); + await expect(listLocalFonts()).resolves.toBeNull(); + }); + + it("queries once per session so the prompt fires at most once", async () => { + const query = vi + .fn() + .mockResolvedValue([face("Inter", "Regular", "Inter-Regular")]); + setQuery(query); + + const [first, second] = await Promise.all([ + listLocalFonts(), + listLocalFonts(), + ]); + await listLocalFonts(); + + expect(query).toHaveBeenCalledTimes(1); + expect(first).toBe(second); + + resetLocalFontsCache(); + await listLocalFonts(); + expect(query).toHaveBeenCalledTimes(2); + }); + + it("memoises a denial instead of re-prompting", async () => { + const error = new Error("denied"); + error.name = "NotAllowedError"; + const query = vi.fn().mockRejectedValue(error); + setQuery(query); + + await expect(listLocalFonts()).resolves.toBeNull(); + await expect(listLocalFonts()).resolves.toBeNull(); + expect(query).toHaveBeenCalledTimes(1); + }); +}); + +describe("groupByFamily", () => { + it("collapses faces into families sorted case-insensitively", () => { + const grouped = groupByFamily([ + mkFont("inter", "Regular"), + mkFont("Arial", "Bold"), + mkFont("Zapfino", "Regular"), + mkFont("bahnschrift", "Light"), + ]); + expect(grouped.map((f) => f.family)).toEqual([ + "Arial", + "bahnschrift", + "inter", + "Zapfino", + ]); + }); + + it("merges faces of one family and sorts its styles", () => { + const grouped = groupByFamily([ + mkFont("Inter", "Regular"), + mkFont("Inter", "Bold"), + mkFont("inter", "Italic"), + ]); + expect(grouped).toHaveLength(1); + expect(grouped[0]?.family).toBe("Inter"); + expect(grouped[0]?.styles).toEqual(["Bold", "Italic", "Regular"]); + }); + + it("de-duplicates styles case-insensitively and skips empty ones", () => { + const grouped = groupByFamily([ + mkFont("Inter", "Bold"), + mkFont("Inter", "bold"), + mkFont("Inter", ""), + ]); + expect(grouped[0]?.styles).toEqual(["Bold"]); + }); + + it("returns an empty list for no faces", () => { + expect(groupByFamily([])).toEqual([]); + }); +}); diff --git a/frontend/editor/src/core/tools/pdfTextEditor/__tests__/overlayPainter.test.ts b/frontend/editor/src/core/tools/pdfTextEditor/__tests__/overlayPainter.test.ts new file mode 100644 index 0000000000..390bc21d5f --- /dev/null +++ b/frontend/editor/src/core/tools/pdfTextEditor/__tests__/overlayPainter.test.ts @@ -0,0 +1,380 @@ +import { beforeAll, beforeEach, describe, expect, it } from "vitest"; +import { + isLinePainted, + type PaintLine, + paintLines, + paintPlainText, + plainCaretOffset, + readOverlayText, + refitEditedTokens, + restoreCaretOffset, +} from "@app/tools/pdfTextEditor/util/overlayPainter"; + +const OPTS = { font: "normal 400 16px sans-serif", fontSizePx: 16 }; + +/** Line box geometry the advance tests do not care about. */ +const BOX = { heightPx: 20, marginTopPx: 0, marginLeftPx: 0 }; + +function line(text: string, marginTopPx = 0): PaintLine { + const tokens = text + .split(/( +)/) + .filter((t) => t.length > 0) + .map((t) => ({ text: t, advancePx: 10 * t.length })); + return { tokens, heightPx: 20, marginTopPx, marginLeftPx: 0 }; +} + +function host(): HTMLDivElement { + const el = document.createElement("div"); + el.contentEditable = "true"; + document.body.appendChild(el); + return el; +} + +beforeAll(() => { + HTMLCanvasElement.prototype.getContext = (() => ({ + font: "", + letterSpacing: "0px", + measureText: (text: string) => ({ + width: text.length * 8, + fontBoundingBoxAscent: 12, + fontBoundingBoxDescent: 4, + }), + })) as unknown as HTMLCanvasElement["getContext"]; +}); + +beforeEach(() => { + document.body.replaceChildren(); +}); + +describe("paintLines", () => { + it("emits one block per line", () => { + const el = host(); + paintLines(el, [line("Hello world"), line("second line")], OPTS); + expect(el.children).toHaveLength(2); + expect(isLinePainted(el)).toBe(true); + }); + + it("reads back the same text innerText would give", () => { + const el = host(); + paintLines(el, [line("Hello world"), line("second line")], OPTS); + expect(el.textContent).toBe("Hello worldsecond line"); + expect(el.children[0].textContent).toBe("Hello world"); + expect(el.children[1].textContent).toBe("second line"); + }); + + it("gives an empty line a break so it still counts as a line", () => { + const el = host(); + paintLines(el, [line("a"), line(""), line("b")], OPTS); + expect(el.children).toHaveLength(3); + expect(el.children[1].querySelector("br")).not.toBeNull(); + }); + + it("pins each line's own height and gap", () => { + const el = host(); + paintLines(el, [line("a"), line("b", 7.25)], OPTS); + const second = el.children[1] as HTMLElement; + // A FIXED height, never minHeight. A painted block is one line of the PDF - + // one text object at one pen origin - and the page cannot wrap it. Letting + // the block grow put a long line on two rows in the overlay and one on the + // page, pushing every block below it a full line-height out of register. + expect(second.style.height).toBe("20px"); + expect(second.style.minHeight).toBe(""); + expect(second.style.lineHeight).toBe("20px"); + expect(second.style.marginTop).toBe("7.25px"); + }); + + it("never lets a painted line wrap", () => { + const el = host(); + paintLines(el, [line("a long line of text"), line("b")], OPTS); + for (const block of el.children) { + // "inherit" let the container's pre-wrap reach the blocks; the PDF has + // no such thing as a soft break, so neither may these. + expect((block as HTMLElement).style.whiteSpace).toBe("pre"); + } + }); + + it("uses inline tokens, never inline-block", () => { + const el = host(); + paintLines(el, [line("Hello world")], OPTS); + const spans = el.querySelectorAll("span"); + expect(spans.length).toBeGreaterThan(0); + for (const span of spans) { + expect(span.style.display).toBe(""); + } + }); + + it("replaces a previous painting rather than appending to it", () => { + const el = host(); + paintLines(el, [line("first")], OPTS); + paintLines(el, [line("second"), line("third")], OPTS); + expect(el.children).toHaveLength(2); + expect(el.textContent).toBe("secondthird"); + }); +}); + +describe("caret offsets", () => { + it("counts one character per line boundary", () => { + const el = host(); + paintLines(el, [line("Hello world"), line("second line")], OPTS); + const secondLine = el.children[1]; + const textNode = secondLine.firstChild!.firstChild!; + const range = document.createRange(); + range.setStart(textNode, 4); + range.collapse(true); + const selection = window.getSelection()!; + selection.removeAllRanges(); + selection.addRange(range); + expect(plainCaretOffset(el)).toBe(16); + }); + + it("round-trips every offset across a repaint", () => { + const el = host(); + const lines = [line("Hello world"), line("second line")]; + paintLines(el, lines, OPTS); + const total = "Hello world\nsecond line".length; + for (let offset = 0; offset <= total; offset += 1) { + restoreCaretOffset(el, offset); + expect(plainCaretOffset(el)).toBe(offset); + } + }); + + it("round-trips through an empty line", () => { + const el = host(); + paintLines(el, [line("a"), line(""), line("b")], OPTS); + for (const offset of [0, 1, 2, 3]) { + restoreCaretOffset(el, offset); + expect(plainCaretOffset(el)).toBe(offset); + } + }); + + it("works on plain text too, for runs the exact path cannot place", () => { + const el = host(); + paintPlainText(el, "just one line"); + expect(isLinePainted(el)).toBe(false); + el.textContent = "just one line"; + restoreCaretOffset(el, 5); + expect(plainCaretOffset(el)).toBe(5); + }); + + it("clamps past the end instead of throwing", () => { + const el = host(); + paintLines(el, [line("abc")], OPTS); + restoreCaretOffset(el, 999); + expect(plainCaretOffset(el)).toBe(3); + }); + + it("returns null when the caret is somewhere else entirely", () => { + const el = host(); + paintLines(el, [line("abc")], OPTS); + const outside = document.createElement("div"); + outside.textContent = "elsewhere"; + document.body.appendChild(outside); + const range = document.createRange(); + range.setStart(outside.firstChild!, 2); + range.collapse(true); + const selection = window.getSelection()!; + selection.removeAllRanges(); + selection.addRange(range); + expect(plainCaretOffset(el)).toBeNull(); + }); +}); + +describe("readOverlayText", () => { + it("round-trips what paintLines wrote", () => { + const el = host(); + paintLines(el, [line("Hello world"), line("second line")], OPTS); + expect(readOverlayText(el)).toBe("Hello world\nsecond line"); + }); + + // The browser leaves a filler
in a block the user emptied. innerText + // reports that as "\n", which used to add a phantom line and push every + // line below it one leading down the page. + it("reads a block the browser emptied as ONE blank line", () => { + const el = host(); + paintLines(el, [line("4 Park Plaza"), line("Suite 1930")], OPTS); + const first = el.children[0] as HTMLElement; + first.replaceChildren(document.createElement("br")); + expect(readOverlayText(el)).toBe("\nSuite 1930"); + }); + + it("keeps the line count when every block is emptied", () => { + const el = host(); + paintLines(el, [line("a"), line("b"), line("c")], OPTS); + for (const block of Array.from(el.children)) { + block.replaceChildren(document.createElement("br")); + } + expect(readOverlayText(el)).toBe("\n\n"); + }); + + it("keeps a blank first line in the plain
DOM", () => { + const el = host(); + el.append(document.createElement("br"), document.createTextNode("abc")); + expect(readOverlayText(el)).toBe("\nabc"); + }); + + it("drops the browser's trailing filler
", () => { + const el = host(); + el.append(document.createTextNode("abc"), document.createElement("br")); + expect(readOverlayText(el)).toBe("abc"); + }); + + it("reads a lone filler
as empty, not as a line break", () => { + const el = host(); + el.appendChild(document.createElement("br")); + expect(readOverlayText(el)).toBe(""); + }); + + it("normalises non-breaking spaces the browser inserts", () => { + const el = host(); + el.appendChild(document.createTextNode("a\u00A0b")); + expect(readOverlayText(el)).toBe("a b"); + }); +}); + +describe("readOverlayText - browser-emptied blocks", () => { + // Chrome does not always leave a bare
behind. Pressing Enter at the end + // of a line leaves the new block holding an EMPTY CLONE of the token span + // with the filler
inside it - a break the walk must NOT read as a line + // of its own, or one Enter reads back as two. + it("reads a block emptied down to a token span as ONE blank line", () => { + const el = host(); + paintLines(el, [line("Second line"), line("left margin")], OPTS); + const emptied = el.children[0] as HTMLElement; + const leftover = document.createElement("span"); + leftover.setAttribute("data-pdf-editor-token", ""); + leftover.dataset.src = "line"; + leftover.appendChild(document.createElement("br")); + emptied.replaceChildren(leftover); + expect(readOverlayText(el)).toBe("\nleft margin"); + }); + + it("still splits a block that really does hold two lines", () => { + const el = host(); + paintLines(el, [line("one two")], OPTS); + const block = el.children[0] as HTMLElement; + // Firefox spells a manual break as a
INSIDE the token span it split, + // so the reader has to descend to see it. + const span = document.createElement("span"); + span.setAttribute("data-pdf-editor-token", ""); + span.replaceChildren( + document.createTextNode("one"), + document.createElement("br"), + document.createTextNode("two"), + ); + block.replaceChildren(span); + expect(readOverlayText(el)).toBe("one\ntwo"); + }); +}); + +describe("plainCaretOffset - carets that are not in a text node", () => { + // Enter parks the caret inside the empty span Chrome left behind. A tree walk + // over text nodes alone reports nothing for that position, and the repaint + // that follows then dropped the caret to the top of the run. + it("finds a caret parked inside an empty token span", () => { + const el = host(); + paintLines(el, [line("Second line"), line(""), line("left margin")], OPTS); + const blank = el.children[1] as HTMLElement; + const leftover = document.createElement("span"); + leftover.setAttribute("data-pdf-editor-token", ""); + blank.replaceChildren(leftover); + + const range = document.createRange(); + range.setStart(leftover, 0); + range.collapse(true); + const selection = window.getSelection()!; + selection.removeAllRanges(); + selection.addRange(range); + + expect(plainCaretOffset(el)).toBe("Second line\n".length); + }); + + it("finds a caret parked on the container between two blocks", () => { + const el = host(); + paintLines(el, [line("abc"), line("de")], OPTS); + const range = document.createRange(); + range.setStart(el, 1); + range.collapse(true); + const selection = window.getSelection()!; + selection.removeAllRanges(); + selection.addRange(range); + expect(plainCaretOffset(el)).toBe(4); + }); + + it("reads a container caret past the last block as the end of it", () => { + const el = host(); + paintLines(el, [line("abc"), line("de")], OPTS); + const range = document.createRange(); + range.setStart(el, 2); + range.collapse(true); + const selection = window.getSelection()!; + selection.removeAllRanges(); + selection.addRange(range); + expect(plainCaretOffset(el)).toBe("abc\nde".length); + }); +}); + +describe("refitEditedTokens", () => { + // The stub canvas above advances every face at 8px/char, so a token painted + // at a different advance is standing in for a PDF whose own face is wider or + // narrower than the one the browser has. + function tokenOf(el: HTMLElement): HTMLElement { + return el.querySelector("[data-pdf-editor-token]")!; + } + + function fittedWidth(span: HTMLElement, chars: number): number { + const ls = parseFloat(span.style.letterSpacing || "0"); + const mr = parseFloat(span.style.marginRight || "0"); + return chars * 8 + chars * ls + mr; + } + + it("re-prices a token the user typed into against the PDF's own advances", () => { + const el = host(); + paintLines(el, [{ tokens: [{ text: "ab", advancePx: 24 }], ...BOX }], OPTS); + const span = tokenOf(el); + span.textContent = "abcd"; + // "a" and "b" measured 12px each in the PDF; "c"/"d" are new, so they take + // the token's own browser-to-PDF ratio (24/16). + refitEditedTokens(el, { + ...OPTS, + advanceEm: new Map([ + ["a", 12 / OPTS.fontSizePx], + ["b", 12 / OPTS.fontSizePx], + ]), + }); + expect(fittedWidth(span, 4)).toBeCloseTo(48, 4); + }); + + it("falls back to the token's own ratio with no advance table", () => { + const el = host(); + paintLines(el, [{ tokens: [{ text: "ab", advancePx: 24 }], ...BOX }], OPTS); + const span = tokenOf(el); + span.textContent = "abcd"; + refitEditedTokens(el, OPTS); + expect(fittedWidth(span, 4)).toBeCloseTo(48, 4); + }); + + it("restores the exact fit when the edit is backspaced away", () => { + const el = host(); + paintLines(el, [{ tokens: [{ text: "ab", advancePx: 24 }], ...BOX }], OPTS); + const span = tokenOf(el); + const painted = `${span.style.letterSpacing}|${span.style.marginRight}`; + span.textContent = "abcd"; + refitEditedTokens(el, OPTS); + span.textContent = "ab"; + refitEditedTokens(el, OPTS); + expect(`${span.style.letterSpacing}|${span.style.marginRight}`).toBe( + painted, + ); + }); + + it("leaves untouched tokens exactly as painted", () => { + const el = host(); + paintLines(el, [{ tokens: [{ text: "ab", advancePx: 24 }], ...BOX }], OPTS); + const span = tokenOf(el); + const before = `${span.style.letterSpacing}|${span.style.marginRight}`; + refitEditedTokens(el, OPTS); + expect(`${span.style.letterSpacing}|${span.style.marginRight}`).toBe( + before, + ); + }); +}); diff --git a/frontend/editor/src/core/tools/pdfTextEditor/__tests__/pageFonts.test.ts b/frontend/editor/src/core/tools/pdfTextEditor/__tests__/pageFonts.test.ts new file mode 100644 index 0000000000..f94e6e5691 --- /dev/null +++ b/frontend/editor/src/core/tools/pdfTextEditor/__tests__/pageFonts.test.ts @@ -0,0 +1,156 @@ +import { describe, it, expect } from "vitest"; +import { + analyzePageFonts, + missingAlnumFromCmap, +} from "@app/tools/pdfTextEditor/util/pageFonts"; +import type { PageSnapshot } from "@app/tools/pdfTextEditor/types"; + +function mkRun(id: string, fontId: string, fontSubset = false) { + return { + id, + pageIndex: 0, + bounds: { x: 0, y: 0, width: 10, height: 10 }, + matrix: { a: 1, b: 0, c: 0, d: 1, e: 0, f: 0 }, + text: "x", + fontId, + fontSize: 12, + fill: { r: 0, g: 0, b: 0, a: 255 }, + fontSubset, + }; +} +function mkPage(pageIndex: number, runs: ReturnType[]) { + return { + pageIndex, + width: 100, + height: 100, + revision: 0, + dirty: false, + runs, + images: [], + } as unknown as PageSnapshot; +} + +describe("analyzePageFonts", () => { + it("classifies base-14 / standard families as standard", () => { + const fonts = analyzePageFonts([ + mkPage(0, [ + mkRun("a", "base14:Helvetica"), + mkRun("b", "pdf:11:Times-Roman"), + mkRun("c", "pdf:12:Courier"), + ]), + ]); + expect(fonts.every((f) => f.status === "standard")).toBe(true); + }); + + it("classifies a fully embedded non-subset font as embedded", () => { + const fonts = analyzePageFonts([ + mkPage(0, [mkRun("a", "pdf:2212776:LMRoman12", false)]), + ]); + expect(fonts).toHaveLength(1); + expect(fonts[0].status).toBe("embedded"); + expect(fonts[0].name).toBe("LMRoman12"); + }); + + it("flags a non-standard subset font as subset and strips the tag", () => { + const fonts = analyzePageFonts([ + mkPage(0, [mkRun("a", "pdf:9:ABCDEF+LMRoman10", true)]), + ]); + expect(fonts).toHaveLength(1); + expect(fonts[0].status).toBe("subset"); + expect(fonts[0].name).toBe("LMRoman10"); + }); + + it("treats a subset of a standard family as standard (base-14 fallback is safe)", () => { + const fonts = analyzePageFonts([ + mkPage(0, [mkRun("a", "pdf:3:ABCDEF+Helvetica", true)]), + ]); + expect(fonts).toHaveLength(1); + expect(fonts[0].status).toBe("standard"); + }); + + it("classifies base-14 bold/italic variants as standard", () => { + const fonts = analyzePageFonts([ + mkPage(0, [ + mkRun("a", "pdf:1:Helvetica-BoldOblique"), + mkRun("b", "pdf:2:Times-BoldItalic"), + mkRun("c", "pdf:3:Courier-Oblique"), + mkRun("d", "pdf:4:ArialMT"), + ]), + ]); + expect(fonts.every((f) => f.status === "standard")).toBe(true); + }); + + it("does NOT mislabel a custom font that merely contains a base-14 substring", () => { + // "Arial Black" / "Helvetica Neue" are distinct fonts, and a custom font + // with "arial" mid-name is not base-14 - all must fall through to embedded. + const fonts = analyzePageFonts([ + mkPage(0, [ + mkRun("a", "pdf:5:ArialBlack", false), + mkRun("b", "pdf:6:HelveticaNeue", false), + mkRun("c", "pdf:7:MyArialClone", false), + ]), + ]); + expect(fonts.every((f) => f.status !== "standard")).toBe(true); + }); + + it("de-duplicates the same font across pages and records page numbers", () => { + const fonts = analyzePageFonts([ + mkPage(0, [mkRun("a", "pdf:5:LMRoman12", false)]), + mkPage(2, [mkRun("b", "pdf:5:LMRoman12", false)]), + ]); + expect(fonts).toHaveLength(1); + expect(fonts[0].pages).toEqual([1, 3]); + }); + + it("returns nothing when there are no runs", () => { + expect(analyzePageFonts([mkPage(0, [])])).toEqual([]); + }); + + it("reports standard fonts as full a-zA-Z0-9 coverage (no cmap read needed)", () => { + const fonts = analyzePageFonts([ + mkPage(0, [mkRun("a", "base14:Helvetica")]), + ]); + expect(fonts[0].coverage).toEqual({ known: true, missing: [] }); + }); + + it("reports coverage unknown for an embedded font with no primed cmap", () => { + const fonts = analyzePageFonts([ + mkPage(0, [mkRun("a", "pdf:777777:LMRoman12", false)]), + ]); + expect(fonts[0].coverage.known).toBe(false); + }); +}); + +describe("missingAlnumFromCmap", () => { + function cmapWith(...codepoints: number[]): Map { + const m = new Map(); + for (const cp of codepoints) m.set(cp, cp + 1); // glyphId is arbitrary + return m; + } + const ALL = (() => { + const cps: number[] = []; + for (let c = 0x30; c <= 0x39; c++) cps.push(c); + for (let c = 0x41; c <= 0x5a; c++) cps.push(c); + for (let c = 0x61; c <= 0x7a; c++) cps.push(c); + return cps; + })(); + + it("returns [] when every a-zA-Z0-9 glyph is present", () => { + expect(missingAlnumFromCmap(cmapWith(...ALL))).toEqual([]); + }); + + it("lists exactly the absent alphanumerics", () => { + const present = ALL.filter((c) => c !== 0x71 && c !== 0x57 && c !== 0x37); + expect(missingAlnumFromCmap(cmapWith(...present)).sort()).toEqual( + ["7", "W", "q"].sort(), + ); + }); + + it("reports all 62 missing for an empty cmap", () => { + expect(missingAlnumFromCmap(new Map()).length).toBe(62); + }); + + it("ignores non-alphanumeric glyphs in the cmap", () => { + expect(missingAlnumFromCmap(cmapWith(0x21, 0x2e, 0x2c)).length).toBe(62); + }); +}); diff --git a/frontend/editor/src/core/tools/pdfTextEditor/__tests__/pdfFixtures.ts b/frontend/editor/src/core/tools/pdfTextEditor/__tests__/pdfFixtures.ts new file mode 100644 index 0000000000..62515d36d6 --- /dev/null +++ b/frontend/editor/src/core/tools/pdfTextEditor/__tests__/pdfFixtures.ts @@ -0,0 +1,140 @@ +// Hand-assembled PDFs for the raw-PDF tests: small enough to reason about +// byte by byte, where a library fixture would hide the structural variation. +import { fromLatin1 } from "@app/tools/pdfTextEditor/pdfdoc/bytes"; + +export interface FixtureObject { + num: number; + body: string; +} + +/** Build a stream object body with a correct `/Length`. */ +export function streamBody(dictInner: string, data: string): string { + const inner = dictInner.trim(); + const sep = inner.length ? `${inner} ` : ""; + return `<< ${sep}/Length ${data.length} >>\nstream\n${data}\nendstream`; +} + +/** Assemble objects into a PDF with a classic cross-reference table. */ +export function buildClassicPdf( + objects: FixtureObject[], + rootNum: number, +): Uint8Array { + const sorted = [...objects].sort((a, b) => a.num - b.num); + let out = "%PDF-1.7\n"; + const offsets = new Map(); + for (const obj of sorted) { + offsets.set(obj.num, out.length); + out += `${obj.num} 0 obj\n${obj.body}\nendobj\n`; + } + const xrefAt = out.length; + const size = sorted[sorted.length - 1].num + 1; + out += `xref\n0 ${size}\n0000000000 65535 f \n`; + for (let num = 1; num < size; num += 1) { + const off = offsets.get(num); + out += + off === undefined + ? "0000000000 65535 f \n" + : `${String(off).padStart(10, "0")} 00000 n \n`; + } + out += `trailer\n<< /Size ${size} /Root ${rootNum} 0 R /ID [ ] >>\n`; + out += `startxref\n${xrefAt}\n%%EOF`; + return fromLatin1(out); +} + +/** Assemble objects into a PDF whose newest xref section is a stream. */ +export function buildXrefStreamPdf( + objects: FixtureObject[], + rootNum: number, + /** objNum -> containing ObjStm number, emitted as a type-2 xref row. */ + compressed?: Map, +): Uint8Array { + const sorted = [...objects].sort((a, b) => a.num - b.num); + let out = "%PDF-1.7\n"; + const offsets = new Map(); + for (const obj of sorted) { + offsets.set(obj.num, out.length); + out += `${obj.num} 0 obj\n${obj.body}\nendobj\n`; + } + const xrefNum = sorted[sorted.length - 1].num + 1; + const size = xrefNum + 1; + const xrefAt = out.length; + offsets.set(xrefNum, xrefAt); + let rows = ""; + for (let num = 0; num < size; num += 1) { + const container = compressed?.get(num); + if (container !== undefined) { + // Type 2: field 2 is the container number, field 3 the index within it. + rows += String.fromCharCode( + 2, + (container >>> 24) & 0xff, + (container >>> 16) & 0xff, + (container >>> 8) & 0xff, + container & 0xff, + 0, + 0, + ); + continue; + } + const off = offsets.get(num) ?? 0; + const type = num === 0 ? 0 : offsets.has(num) ? 1 : 0; + rows += String.fromCharCode( + type, + (off >>> 24) & 0xff, + (off >>> 16) & 0xff, + (off >>> 8) & 0xff, + off & 0xff, + 0, + num === 0 ? 0xff : 0, + ); + } + const dict = + `<< /Type /XRef /W [1 4 2] /Size ${size} /Root ${rootNum} 0 R ` + + `/ID [ ] /Length ${rows.length} >>`; + out += `${xrefNum} 0 obj\n${dict}\nstream\n${rows}\nendstream\nendobj\n`; + out += `startxref\n${xrefAt}\n%%EOF`; + return fromLatin1(out); +} + +/** A one-page document whose `/Contents` is a single stream. */ +export function singleContentPdf( + content = "BT /F1 12 Tf (hi) Tj ET", +): Uint8Array { + return buildClassicPdf( + [ + { num: 1, body: "<< /Type /Catalog /Pages 2 0 R >>" }, + { num: 2, body: "<< /Type /Pages /Kids [3 0 R] /Count 1 >>" }, + { + num: 3, + body: + "<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] " + + "/Resources << /Font << /F1 5 0 R >> >> /Contents 4 0 R >>", + }, + { num: 4, body: streamBody("", content) }, + { + num: 5, + body: "<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>", + }, + ], + 1, + ); +} + +// One page whose `/Contents` is an array; `tight` omits the separator, the +// shape a naive splice fuses into one token. +export function splitContentPdf(parts: string[], tight = false): Uint8Array { + const refs = parts.map((_, i) => `${4 + i} 0 R`).join(" "); + const objects: FixtureObject[] = [ + { num: 1, body: "<< /Type /Catalog /Pages 2 0 R >>" }, + { num: 2, body: "<< /Type /Pages /Kids [3 0 R] /Count 1 >>" }, + { + num: 3, + body: + "<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] " + + `/Resources << >> /Contents${tight ? "" : " "}[${refs}] >>`, + }, + ]; + parts.forEach((p, i) => + objects.push({ num: 4 + i, body: streamBody("", p) }), + ); + return buildClassicPdf(objects, 1); +} diff --git a/frontend/editor/src/core/tools/pdfTextEditor/__tests__/pdfPasses.test.ts b/frontend/editor/src/core/tools/pdfTextEditor/__tests__/pdfPasses.test.ts new file mode 100644 index 0000000000..419d852bac --- /dev/null +++ b/frontend/editor/src/core/tools/pdfTextEditor/__tests__/pdfPasses.test.ts @@ -0,0 +1,296 @@ +import { describe, expect, it } from "vitest"; +import { toLatin1 } from "@app/tools/pdfTextEditor/pdfdoc/bytes"; +import { parseOps, tokenize } from "@app/tools/pdfTextEditor/pdfdoc/contentOps"; +import { RawPdf } from "@app/tools/pdfTextEditor/pdfdoc/raw"; +import { consolidateContents } from "@app/tools/pdfTextEditor/pdfdoc/passes/consolidateContents"; +import { + extractShadingDraws, + preserveShadings, +} from "@app/tools/pdfTextEditor/pdfdoc/passes/preserveShadings"; +import { prepareForEditing } from "@app/tools/pdfTextEditor/pdfdoc/prepareForEditing"; +import { + buildClassicPdf, + singleContentPdf, + splitContentPdf, + streamBody, +} from "@app/tools/pdfTextEditor/__tests__/pdfFixtures"; + +describe("content-stream tokeniser", () => { + it("treats a parenthesised string as one token even with escapes", () => { + const tokens = tokenize("BT (a \\) b (c) d) Tj ET"); + expect(tokens.map((t) => t.text)).toEqual([ + "BT", + "(a \\) b (c) d)", + "Tj", + "ET", + ]); + }); + + it("groups operands with their operator", () => { + const ops = parseOps("1 0 0 1 20 30 cm /Sh0 sh"); + expect(ops).toHaveLength(2); + expect(ops[0].op).toBe("cm"); + expect(ops[0].operands).toEqual(["1", "0", "0", "1", "20", "30"]); + expect(ops[1].op).toBe("sh"); + expect(ops[1].operands).toEqual(["/Sh0"]); + }); + + it("does not lex the binary payload of an inline image", () => { + const ops = parseOps("BI /W 2 ID \u0001q\u0000Q EI Q"); + expect(ops.map((o) => o.op)).toEqual(["BI", "EI", "Q"]); + }); + + it("does not treat true/false/R as operators", () => { + const ops = parseOps("/GS0 gs true /X Do"); + expect(ops.map((o) => o.op)).toEqual(["gs", "Do"]); + }); +}); + +describe("consolidateContents", () => { + it("merges a multi-part /Contents array into a single stream", async () => { + const original = splitContentPdf(["q 1 0 0", "1 0 0 cm", "Q"]); + const result = await consolidateContents(original); + expect(result?.pages).toEqual([0]); + + const pdf = await RawPdf.parse(result?.bytes as Uint8Array); + const pageNum = pdf?.pageNumberAt(0) ?? 0; + const body = pdf?.objectBody(pageNum) ?? ""; + expect(pdf?.contentRefs(body)).toHaveLength(1); + + const content = toLatin1( + (await pdf?.pageContent(pageNum)) ?? new Uint8Array(), + ); + expect(content.replace(/\s+/g, " ").trim()).toBe("q 1 0 0 1 0 0 cm Q"); + }); + + it("separates the parts so two of them cannot fuse into one token", async () => { + const result = await consolidateContents( + splitContentPdf(["1 0 0 1 0 0", "cm"]), + ); + const pdf = await RawPdf.parse(result?.bytes as Uint8Array); + const content = toLatin1( + (await pdf?.pageContent(pdf?.pageNumberAt(0) ?? 0)) ?? new Uint8Array(), + ); + expect(parseOps(content).map((o) => o.op)).toEqual(["cm"]); + }); + + it("keeps the rewritten reference lexable when /Contents has no separator", async () => { + const original = splitContentPdf(["q", "Q"], true); + expect(toLatin1(original)).toContain("/Contents["); + const result = await consolidateContents(original); + const pdf = await RawPdf.parse(result?.bytes as Uint8Array); + const pageNum = pdf?.pageNumberAt(0) ?? 0; + const body = pdf?.objectBody(pageNum) ?? ""; + // Splicing without a gap yields the single name token "/Contents5", + // which costs the page every one of its objects. + expect(body).not.toMatch(/\/Contents\d/); + expect(pdf?.contentRefs(body)).toHaveLength(1); + const content = toLatin1( + (await pdf?.pageContent(pageNum)) ?? new Uint8Array(), + ); + expect(content.replace(/\s+/g, " ").trim()).toBe("q Q"); + }); + + it("leaves a single-stream document alone", async () => { + expect(await consolidateContents(singleContentPdf())).toBeNull(); + }); + + it("leaves the original bytes intact", async () => { + const original = splitContentPdf(["q", "Q"]); + const result = await consolidateContents(original); + const out = result?.bytes as Uint8Array; + expect(out.slice(0, original.length)).toEqual(original); + }); +}); + +describe("prepareForEditing", () => { + it("merges split content streams on the way in", async () => { + const out = await prepareForEditing(splitContentPdf(["q", "Q"])); + const pdf = await RawPdf.parse(out); + const body = pdf?.objectBody(pdf?.pageNumberAt(0) ?? 0) ?? ""; + expect(pdf?.contentRefs(body)).toHaveLength(1); + }); + + it("returns the same buffer when there is nothing to repair", async () => { + const original = singleContentPdf(); + expect(await prepareForEditing(original)).toBe(original); + }); + + it("returns the input untouched rather than throwing on a broken file", async () => { + const broken = new Uint8Array([ + 0x25, 0x50, 0x44, 0x46, 0x2d, 0x31, 0xff, 0xfe, + ]); + expect(await prepareForEditing(broken)).toBe(broken); + }); +}); + +describe("extractShadingDraws", () => { + it("returns null for a page with no shading", () => { + expect(extractShadingDraws("BT (hi) Tj ET")).toBeNull(); + }); + + it("keeps the state that positions the shading and drops the text", () => { + const extracted = extractShadingDraws( + "q 1 0 0 1 10 20 cm /GS0 gs /Sh0 sh Q BT /F1 12 Tf (hello) Tj ET", + ); + expect(extracted).not.toBeNull(); + expect(extracted?.content).toContain("1 0 0 1 10 20 cm"); + expect(extracted?.content).toContain("/GS0 gs"); + expect(extracted?.content).toContain("/Sh0 sh"); + expect(extracted?.content).not.toContain("Tj"); + expect(extracted?.content).not.toContain("Tf"); + expect(extracted?.needs.shading).toEqual(["Sh0"]); + expect(extracted?.needs.extGState).toEqual(["GS0"]); + }); + + it("keeps a clip path but paints nothing else", () => { + const extracted = extractShadingDraws( + "q 0 0 100 100 re W n 1 0 0 rg 5 5 10 10 re f /Sh0 sh Q", + ); + expect(extracted?.content).toContain("0 0 100 100 re"); + expect(extracted?.content).toContain("W"); + // The filled rectangle must survive as a path but never be painted. + expect(extracted?.content).not.toMatch(/(^|\n)f($|\n)/); + expect(extracted?.content).toContain("5 5 10 10 re"); + }); + + it("balances a stream whose q/Q pairs the generator left dangling", () => { + const extracted = extractShadingDraws("q q /Sh0 sh"); + const ops = parseOps(extracted?.content ?? ""); + const opens = ops.filter((o) => o.op === "q").length; + const closes = ops.filter((o) => o.op === "Q").length; + expect(opens).toBe(closes); + }); + + it("recognises a shading drawn before any text as a background", () => { + expect(extractShadingDraws("/Sh0 sh BT (x) Tj ET")?.isBackground).toBe( + true, + ); + expect(extractShadingDraws("BT (x) Tj ET /Sh0 sh")?.isBackground).toBe( + false, + ); + }); + + it("ignores shadings drawn inside a form XObject, which regeneration keeps", () => { + expect(extractShadingDraws("q /Fm0 Do Q")).toBeNull(); + }); +}); + +/** A page whose gradient PDFium would drop, plus the saved file without it. */ +function shadingFixtures(): { original: Uint8Array; saved: Uint8Array } { + const resources = + "/Resources << /Shading << /Sh0 6 0 R >> /Font << /F1 5 0 R >> >>"; + const pageBody = (contents: string): string => + `<< /Type /Page /Parent 2 0 R /MediaBox [0 0 200 200] ${resources} /Contents ${contents} >>`; + const common = [ + { num: 1, body: "<< /Type /Catalog /Pages 2 0 R >>" }, + { num: 2, body: "<< /Type /Pages /Kids [3 0 R] /Count 1 >>" }, + { num: 5, body: "<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>" }, + { num: 6, body: "<< /ShadingType 2 /ColorSpace /DeviceRGB >>" }, + ]; + return { + original: buildClassicPdf( + [ + ...common, + { num: 3, body: pageBody("4 0 R") }, + { + num: 4, + body: streamBody( + "", + "q 200 0 0 200 0 0 cm /Sh0 sh Q BT /F1 12 Tf (hello) Tj ET", + ), + }, + ], + 1, + ), + saved: buildClassicPdf( + [ + ...common, + { num: 3, body: pageBody("4 0 R") }, + { num: 4, body: streamBody("", "BT /F1 12 Tf (hello there) Tj ET") }, + ], + 1, + ), + }; +} + +describe("preserveShadings", () => { + it("puts a dropped background gradient back, underneath the page content", async () => { + const { original, saved } = shadingFixtures(); + const out = await preserveShadings(saved, original, { pages: [0] }); + expect(out).not.toBeNull(); + + const pdf = await RawPdf.parse(out as Uint8Array); + const pageNum = pdf?.pageNumberAt(0) ?? 0; + const refs = pdf?.contentRefs(pdf?.objectBody(pageNum) ?? "") ?? []; + expect(refs).toHaveLength(2); + + const first = toLatin1( + (await pdf?.streamData(refs[0])) ?? new Uint8Array(), + ); + expect(first).toContain("/Sh0 sh"); + expect(first).toContain("200 0 0 200 0 0 cm"); + + const second = toLatin1( + (await pdf?.streamData(refs[1])) ?? new Uint8Array(), + ); + expect(second).toContain("hello there"); + }); + + it("does nothing when no page was regenerated", async () => { + const { original, saved } = shadingFixtures(); + expect(await preserveShadings(saved, original, { pages: [] })).toBeNull(); + }); + + it("declines when the saved file no longer declares the shading resource", async () => { + const { original } = shadingFixtures(); + const saved = buildClassicPdf( + [ + { num: 1, body: "<< /Type /Catalog /Pages 2 0 R >>" }, + { num: 2, body: "<< /Type /Pages /Kids [3 0 R] /Count 1 >>" }, + { + num: 3, + body: + "<< /Type /Page /Parent 2 0 R /MediaBox [0 0 200 200] " + + "/Resources << >> /Contents 4 0 R >>", + }, + { num: 4, body: streamBody("", "BT (hello) Tj ET") }, + ], + 1, + ); + expect(await preserveShadings(saved, original, { pages: [0] })).toBeNull(); + }); + + it("leaves the saved bytes intact when it does apply", async () => { + const { original, saved } = shadingFixtures(); + const out = (await preserveShadings(saved, original, { + pages: [0], + })) as Uint8Array; + expect(out.slice(0, saved.length)).toEqual(saved); + }); +}); + +describe("shading phase ordering", () => { + it("keeps a background shading before the text and a later one after", () => { + const content = + "/ShBack sh BT /F1 12 Tf (hello) Tj ET q 1 0 0 1 5 5 cm /ShOver sh Q"; + const back = extractShadingDraws(content, "background"); + const over = extractShadingDraws(content, "foreground"); + expect(back?.needs.shading).toEqual(["ShBack"]); + expect(over?.needs.shading).toEqual(["ShOver"]); + // The foreground fragment still replays the state that positions it. + expect(over?.content).toContain("1 0 0 1 5 5 cm"); + expect(over?.content).not.toContain("/ShBack sh"); + }); + + it("reports no fragment for a phase with no shading in it", () => { + const content = "/Sh0 sh BT (x) Tj ET"; + expect(extractShadingDraws(content, "foreground")).toBeNull(); + expect(extractShadingDraws(content, "background")).not.toBeNull(); + }); + + it("treats every shading as background when the page has no text", () => { + const content = "q /Sh0 sh Q"; + expect(extractShadingDraws(content, "background")?.isBackground).toBe(true); + }); +}); diff --git a/frontend/editor/src/core/tools/pdfTextEditor/__tests__/pdfRevision.test.ts b/frontend/editor/src/core/tools/pdfTextEditor/__tests__/pdfRevision.test.ts new file mode 100644 index 0000000000..98f7103fcb --- /dev/null +++ b/frontend/editor/src/core/tools/pdfTextEditor/__tests__/pdfRevision.test.ts @@ -0,0 +1,149 @@ +import { describe, expect, it } from "vitest"; +import { fromLatin1, toLatin1 } from "@app/tools/pdfTextEditor/pdfdoc/bytes"; +import { RawPdf } from "@app/tools/pdfTextEditor/pdfdoc/raw"; +import { + appendRevision, + plainObject, + streamObject, +} from "@app/tools/pdfTextEditor/pdfdoc/revision"; +import { + buildXrefStreamPdf, + singleContentPdf, + streamBody, +} from "@app/tools/pdfTextEditor/__tests__/pdfFixtures"; + +/** The property that makes an incremental revision safe for signed files. */ +function prefixIsIntact(before: Uint8Array, after: Uint8Array): boolean { + if (after.length < before.length) return false; + for (let i = 0; i < before.length; i += 1) { + if (before[i] !== after[i]) return false; + } + return true; +} + +describe("appendRevision", () => { + it("leaves every original byte in place", async () => { + const original = singleContentPdf(); + const pdf = await RawPdf.parse(original); + expect(pdf).not.toBeNull(); + const out = appendRevision(pdf as RawPdf, [ + { num: 6, body: plainObject("<< /Added true >>") }, + ]); + expect(out).not.toBeNull(); + expect(prefixIsIntact(original, out as Uint8Array)).toBe(true); + }); + + it("makes the appended object readable and shadows an existing one", async () => { + const pdf = await RawPdf.parse(singleContentPdf()); + const out = appendRevision(pdf as RawPdf, [ + { num: 1, body: plainObject("<< /Type /Catalog /Pages 2 0 R /V 2 >>") }, + { num: 6, body: plainObject("<< /Added true >>") }, + ]); + const reparsed = await RawPdf.parse(out as Uint8Array); + expect(reparsed?.objectBody(6)).toContain("/Added true"); + expect(reparsed?.objectBody(1)).toContain("/V 2"); + expect(reparsed?.rootNum).toBe(1); + }); + + it("writes a classic table whose /Prev points at the previous section", async () => { + const pdf = await RawPdf.parse(singleContentPdf()); + const text = toLatin1( + appendRevision(pdf as RawPdf, [ + { num: 6, body: plainObject("<< >>") }, + ]) as Uint8Array, + ); + expect(text).toContain("trailer"); + expect(text).toMatch(/\/Prev \d+/); + expect(text.trimEnd().endsWith("%%EOF")).toBe(true); + }); + + it("writes a cross-reference STREAM when the source file uses one", async () => { + const original = buildXrefStreamPdf( + [ + { num: 1, body: "<< /Type /Catalog /Pages 2 0 R >>" }, + { num: 2, body: "<< /Type /Pages /Kids [3 0 R] /Count 1 >>" }, + { num: 3, body: "<< /Type /Page /Parent 2 0 R /Contents 4 0 R >>" }, + { num: 4, body: streamBody("", "q Q") }, + ], + 1, + ); + const pdf = await RawPdf.parse(original); + const out = appendRevision(pdf as RawPdf, [ + { + num: 3, + body: plainObject("<< /Type /Page /Parent 2 0 R /Rotate 90 >>"), + }, + ]) as Uint8Array; + const tail = toLatin1(out).slice(original.length); + // A classic table here would be a structure readers reject. + expect(tail).not.toContain("\ntrailer"); + expect(tail).toContain("/Type /XRef"); + expect(prefixIsIntact(original, out)).toBe(true); + const reparsed = await RawPdf.parse(out); + expect(reparsed?.objectBody(3)).toContain("/Rotate 90"); + }); + + it("never gives the xref stream a number the batch already uses", async () => { + const original = buildXrefStreamPdf( + [ + { num: 1, body: "<< /Type /Catalog /Pages 2 0 R >>" }, + { num: 2, body: "<< /Type /Pages /Kids [3 0 R] /Count 1 >>" }, + { num: 3, body: "<< /Type /Page /Parent 2 0 R /Contents 4 0 R >>" }, + { num: 4, body: streamBody("", "q Q") }, + ], + 1, + ); + const pdf = await RawPdf.parse(original); + // Callers allocate from the same high-water mark this used to use, so the + // first new object and the xref stream collided. + const newNum = (pdf as RawPdf).highestObjectNumber + 1; + const out = appendRevision(pdf as RawPdf, [ + { num: newNum, body: streamObject("<< >>", fromLatin1("q Q")) }, + { + num: 3, + body: plainObject( + `<< /Type /Page /Parent 2 0 R /Contents ${newNum} 0 R >>`, + ), + }, + ]) as Uint8Array; + + const text = toLatin1(out); + expect( + text.match(new RegExp(`(^|[^0-9])${newNum} 0 obj`, "g")), + ).toHaveLength(1); + const reparsed = await RawPdf.parse(out); + expect(reparsed?.objectBody(newNum)).not.toContain("/Type /XRef"); + expect( + toLatin1((await reparsed?.streamData(newNum)) ?? new Uint8Array()), + ).toBe("q Q"); + }); + + it("round-trips a stream object it wrote", async () => { + const pdf = await RawPdf.parse(singleContentPdf()); + const payload = fromLatin1("0 0 1 rg 10 10 50 50 re f"); + const out = appendRevision(pdf as RawPdf, [ + { num: 7, body: streamObject("<< >>", payload) }, + ]) as Uint8Array; + const reparsed = await RawPdf.parse(out); + const back = await reparsed?.streamData(7); + expect(toLatin1(back ?? new Uint8Array())).toBe( + "0 0 1 rg 10 10 50 50 re f", + ); + }); + + it("refuses a batch that writes the same object twice", async () => { + const pdf = await RawPdf.parse(singleContentPdf()); + expect( + appendRevision(pdf as RawPdf, [ + { num: 6, body: plainObject("<< /A 1 >>") }, + { num: 6, body: plainObject("<< /A 2 >>") }, + ]), + ).toBeNull(); + }); + + it("returns the input unchanged when there is nothing to write", async () => { + const original = singleContentPdf(); + const pdf = await RawPdf.parse(original); + expect(appendRevision(pdf as RawPdf, [])).toBe(original); + }); +}); diff --git a/frontend/editor/src/core/tools/pdfTextEditor/__tests__/prepareFixtures.test.ts b/frontend/editor/src/core/tools/pdfTextEditor/__tests__/prepareFixtures.test.ts new file mode 100644 index 0000000000..85712c8e57 --- /dev/null +++ b/frontend/editor/src/core/tools/pdfTextEditor/__tests__/prepareFixtures.test.ts @@ -0,0 +1,55 @@ +import { readFileSync, readdirSync } from "node:fs"; +import path from "node:path"; +import { describe, expect, it } from "vitest"; +import { toLatin1 } from "@app/tools/pdfTextEditor/pdfdoc/bytes"; +import { RawPdf } from "@app/tools/pdfTextEditor/pdfdoc/raw"; +import { prepareForEditing } from "@app/tools/pdfTextEditor/pdfdoc/prepareForEditing"; + +// Held to the real corpus, not hand-built fixtures: whatever the load-time +// repair returns must still be the same document. +const FIXTURES = path.resolve(__dirname, "../../../tests/test-fixtures"); + +const pdfs = readdirSync(FIXTURES) + .filter((name) => name.toLowerCase().endsWith(".pdf")) + .sort(); + +describe("prepareForEditing over the real fixture corpus", () => { + it("finds fixtures to check", () => { + expect(pdfs.length).toBeGreaterThan(5); + }); + + for (const name of pdfs) { + it(`preserves every page of ${name}`, async () => { + const original = new Uint8Array(readFileSync(path.join(FIXTURES, name))); + const prepared = await prepareForEditing(original); + + if (prepared === original) return; // untouched is always correct + + const before = await RawPdf.parse(original); + const after = await RawPdf.parse(prepared); + expect(after).not.toBeNull(); + expect(after?.pageNumbers().length).toBe(before?.pageNumbers().length); + + // Every original byte must still be there: the repair only appends. + expect(prepared.slice(0, original.length)).toEqual(original); + + // And each page's content must still decode to the same operators. + const pageCount = after?.pageNumbers().length ?? 0; + for (let i = 0; i < pageCount; i += 1) { + const oldContent = await before?.pageContent( + before.pageNumberAt(i) ?? 0, + ); + const newContent = await after?.pageContent(after.pageNumberAt(i) ?? 0); + if (!oldContent || !newContent) continue; + expect(normalise(toLatin1(newContent))).toBe( + normalise(toLatin1(oldContent)), + ); + } + }); + } +}); + +/** Whitespace between operators is not significant. */ +function normalise(content: string): string { + return content.replace(/\s+/g, " ").trim(); +} diff --git a/frontend/editor/src/core/tools/pdfTextEditor/__tests__/rawPdf.test.ts b/frontend/editor/src/core/tools/pdfTextEditor/__tests__/rawPdf.test.ts new file mode 100644 index 0000000000..4d1db9b122 --- /dev/null +++ b/frontend/editor/src/core/tools/pdfTextEditor/__tests__/rawPdf.test.ts @@ -0,0 +1,238 @@ +import { describe, expect, it } from "vitest"; +import { + fromLatin1, + toLatin1, + undoPngPredictor, +} from "@app/tools/pdfTextEditor/pdfdoc/bytes"; +import { RawPdf } from "@app/tools/pdfTextEditor/pdfdoc/raw"; +import { + buildClassicPdf, + buildXrefStreamPdf, + singleContentPdf, + splitContentPdf, + streamBody, +} from "@app/tools/pdfTextEditor/__tests__/pdfFixtures"; + +describe("RawPdf.parse", () => { + it("indexes objects and resolves the catalogue of a classic-xref file", async () => { + const pdf = await RawPdf.parse(singleContentPdf()); + expect(pdf).not.toBeNull(); + expect(pdf?.rootNum).toBe(1); + expect(pdf?.usesXrefStream).toBe(false); + expect(pdf?.objectBody(1)).toContain("/Type /Catalog"); + }); + + it("finds the catalogue of a cross-reference-stream file, which has no trailer keyword", async () => { + const bytes = buildXrefStreamPdf( + [ + { num: 1, body: "<< /Type /Catalog /Pages 2 0 R >>" }, + { num: 2, body: "<< /Type /Pages /Kids [3 0 R] /Count 1 >>" }, + { num: 3, body: "<< /Type /Page /Parent 2 0 R /Contents 4 0 R >>" }, + { num: 4, body: streamBody("", "q Q") }, + ], + 1, + ); + expect(toLatin1(bytes)).not.toContain("trailer"); + const pdf = await RawPdf.parse(bytes); + expect(pdf?.rootNum).toBe(1); + expect(pdf?.usesXrefStream).toBe(true); + }); + + it("returns null for bytes that are not a PDF", async () => { + expect(await RawPdf.parse(fromLatin1("not a pdf at all"))).toBeNull(); + }); + + it("takes the last definition of an object, so an appended revision wins", async () => { + const base = toLatin1(singleContentPdf()); + const updated = fromLatin1( + `${base}\n1 0 obj\n<< /Type /Catalog /Pages 2 0 R /Marker true >>\nendobj\n`, + ); + const pdf = await RawPdf.parse(updated); + expect(pdf?.objectBody(1)).toContain("/Marker true"); + }); + + it("does not mistake a longer object number for a shorter one", async () => { + const pdf = await RawPdf.parse( + buildClassicPdf( + [ + { num: 1, body: "<< /Type /Catalog /Pages 2 0 R >>" }, + { num: 2, body: "<< /Type /Pages /Kids [912 0 R] /Count 1 >>" }, + { num: 12, body: "<< /Decoy true >>" }, + { num: 912, body: "<< /Type /Page /Parent 2 0 R >>" }, + ], + 1, + ), + ); + expect(pdf?.objectBody(12)).toContain("/Decoy true"); + expect(pdf?.pageNumbers()).toEqual([912]); + }); +}); + +describe("RawPdf.valueSpan", () => { + it("reads a value from the outermost dictionary only", async () => { + const pdf = await RawPdf.parse(singleContentPdf()); + const body = + "<< /Type /Page /Annots [<< /Contents 99 0 R >>] /Contents 7 0 R >>"; + expect(pdf?.dictRef(body, "Contents")).toBe(7); + }); + + it("is not fooled by a key name appearing inside a string", async () => { + const pdf = await RawPdf.parse(singleContentPdf()); + const body = "<< /Title (a /Contents 42 0 R decoy) /Contents 8 0 R >>"; + expect(pdf?.dictRef(body, "Contents")).toBe(8); + }); + + it("treats an indirect reference as one value rather than an integer", async () => { + const pdf = await RawPdf.parse(singleContentPdf()); + const body = "<< /Length 12 0 R >>"; + expect(pdf?.dictInt(body, "Length")).toBeNull(); + expect(pdf?.dictRef(body, "Length")).toBe(12); + }); + + it("reads names and arrays", async () => { + const pdf = await RawPdf.parse(singleContentPdf()); + const body = "<< /Type /Page /Kids [1 0 R 2 0 R] >>"; + expect(pdf?.dictName(body, "Type")).toBe("Page"); + expect(pdf?.valueSpan(body, "Kids")?.text).toBe("[1 0 R 2 0 R]"); + }); +}); + +describe("RawPdf streams and pages", () => { + it("reads an uncompressed stream's payload", async () => { + const pdf = await RawPdf.parse(singleContentPdf("q 1 0 0 1 0 0 cm Q")); + const data = await pdf?.streamData(4); + expect(toLatin1(data ?? new Uint8Array())).toBe("q 1 0 0 1 0 0 cm Q"); + }); + + it("recovers when /Length is wrong by falling back to the endstream keyword", async () => { + const bytes = buildClassicPdf( + [ + { num: 1, body: "<< /Type /Catalog /Pages 2 0 R >>" }, + { num: 2, body: "<< /Type /Pages /Kids [3 0 R] /Count 1 >>" }, + { num: 3, body: "<< /Type /Page /Parent 2 0 R /Contents 4 0 R >>" }, + { num: 4, body: "<< /Length 9999 >>\nstream\nHELLO\nendstream" }, + ], + 1, + ); + const pdf = await RawPdf.parse(bytes); + expect(toLatin1((await pdf?.streamData(4)) ?? new Uint8Array())).toBe( + "HELLO", + ); + }); + + it("prefers the ObjStm copy when the newest xref calls the object compressed", async () => { + // A stale top-level body for the same number must not win. + const inner = "<< /Type /Page /Parent 2 0 R /Contents 9 0 R >>"; + const first = `3 0 ${inner}`; + const objStm = + `<< /Type /ObjStm /N 1 /First ${"3 0 ".length} \n/Length ${first.length} >>` + + `\nstream\n${first}\nendstream`; + const bytes = buildXrefStreamPdf( + [ + { num: 1, body: "<< /Type /Catalog /Pages 2 0 R >>" }, + { num: 2, body: "<< /Type /Pages /Kids [3 0 R] /Count 1 >>" }, + { num: 3, body: "<< /Type /Page /Contents 4 0 R /Stale true >>" }, + { num: 8, body: objStm }, + ], + 1, + new Map([[3, 8]]), + ); + const pdf = await RawPdf.parse(bytes); + expect(pdf?.objectBody(3)).toContain("/Contents 9 0 R"); + expect(pdf?.objectBody(3)).not.toContain("/Stale"); + }); + + it("reads a direct /Filter name rather than treating it as unreadable", async () => { + const pdf = await RawPdf.parse(singleContentPdf("BT ET")); + expect(toLatin1((await pdf?.streamData(4)) ?? new Uint8Array())).toBe( + "BT ET", + ); + }); + + it("walks the page tree in document order, through intermediate nodes", async () => { + const pdf = await RawPdf.parse( + buildClassicPdf( + [ + { num: 1, body: "<< /Type /Catalog /Pages 2 0 R >>" }, + { num: 2, body: "<< /Type /Pages /Kids [3 0 R 6 0 R] /Count 3 >>" }, + { + num: 3, + body: "<< /Type /Pages /Parent 2 0 R /Kids [4 0 R 5 0 R] >>", + }, + { num: 4, body: "<< /Type /Page /Parent 3 0 R >>" }, + { num: 5, body: "<< /Type /Page /Parent 3 0 R >>" }, + { num: 6, body: "<< /Type /Page /Parent 2 0 R >>" }, + ], + 1, + ), + ); + expect(pdf?.pageNumbers()).toEqual([4, 5, 6]); + expect(pdf?.pageNumberAt(1)).toBe(5); + expect(pdf?.pageNumberAt(9)).toBeNull(); + }); + + it("survives a cyclic page tree instead of hanging", async () => { + const pdf = await RawPdf.parse( + buildClassicPdf( + [ + { num: 1, body: "<< /Type /Catalog /Pages 2 0 R >>" }, + { num: 2, body: "<< /Type /Pages /Kids [3 0 R] >>" }, + { + num: 3, + body: "<< /Type /Pages /Parent 2 0 R /Kids [2 0 R 4 0 R] >>", + }, + { num: 4, body: "<< /Type /Page /Parent 3 0 R >>" }, + ], + 1, + ), + ); + expect(pdf?.pageNumbers()).toEqual([4]); + }); + + it("inherits /Resources from an ancestor page-tree node", async () => { + const pdf = await RawPdf.parse( + buildClassicPdf( + [ + { num: 1, body: "<< /Type /Catalog /Pages 2 0 R >>" }, + { + num: 2, + body: + "<< /Type /Pages /Kids [3 0 R] /Count 1 " + + "/Resources << /Shading << /Sh0 9 0 R >> >> >>", + }, + { num: 3, body: "<< /Type /Page /Parent 2 0 R >>" }, + { num: 9, body: "<< /ShadingType 2 >>" }, + ], + 1, + ), + ); + const resources = pdf?.pageInherited(3, "Resources"); + expect(resources).toContain("/Sh0"); + }); + + it("concatenates a multi-part /Contents array in order", async () => { + const pdf = await RawPdf.parse( + splitContentPdf(["q 1 0 0", "1 0 0 cm", "Q"]), + ); + const page = pdf?.pageNumberAt(0) ?? 0; + const content = toLatin1( + (await pdf?.pageContent(page)) ?? new Uint8Array(), + ); + expect(content).toBe("q 1 0 0\n1 0 0 cm\nQ\n"); + }); +}); + +describe("undoPngPredictor", () => { + it("reverses an Up-filtered image back to its original rows", () => { + const rowLen = 3; + // Row 0 is filter 0 (None); row 1 is filter 2 (Up) with deltas. + const encoded = new Uint8Array([0, 10, 20, 30, 2, 1, 2, 3]); + const out = undoPngPredictor(encoded, 1, 8, rowLen); + expect(Array.from(out)).toEqual([10, 20, 30, 11, 22, 33]); + }); + + it("reverses a Sub-filtered row using the left neighbour", () => { + const encoded = new Uint8Array([1, 5, 5, 5]); + expect(Array.from(undoPngPredictor(encoded, 1, 8, 3))).toEqual([5, 10, 15]); + }); +}); diff --git a/frontend/editor/src/core/tools/pdfTextEditor/__tests__/rotation.test.ts b/frontend/editor/src/core/tools/pdfTextEditor/__tests__/rotation.test.ts new file mode 100644 index 0000000000..14d7b2ba8d --- /dev/null +++ b/frontend/editor/src/core/tools/pdfTextEditor/__tests__/rotation.test.ts @@ -0,0 +1,59 @@ +import { describe, it, expect } from "vitest"; +import { + rotationFromMatrix, + counterPageRotation, +} from "@app/tools/pdfTextEditor/commands/editTextHelpers"; + +describe("rotationFromMatrix", () => { + it("returns undefined for upright text (identity / pure scale)", () => { + expect(rotationFromMatrix({ a: 1, b: 0 })).toBeUndefined(); + expect(rotationFromMatrix({ a: 12, b: 0 })).toBeUndefined(); // scale only + }); + + it("extracts normalised cos/sin for a 30deg run, scale-independent", () => { + const r = rotationFromMatrix({ a: 0.866, b: 0.5 })!; + expect(r.cos).toBeCloseTo(0.866, 2); + expect(r.sin).toBeCloseTo(0.5, 2); + // Same angle at 2x scale → same normalised rotation. + const r2 = rotationFromMatrix({ a: 1.732, b: 1.0 })!; + expect(r2.cos).toBeCloseTo(0.866, 2); + expect(r2.sin).toBeCloseTo(0.5, 2); + }); + + it("flags a horizontal flip (negative a) as a rotation", () => { + expect(rotationFromMatrix({ a: -1, b: 0 })).toBeDefined(); + }); + + it("flags a vertical mirror (negative determinant) that a,b alone miss", () => { + // y-flipped generator [1 0 0 -1]: sin~=0, cos>0, so the old a,b-only check + // called it upright and let the surgical horizontal path scatter it. + expect(rotationFromMatrix({ a: 1, b: 0, c: 0, d: -1 })).toBeDefined(); + }); + + it("does NOT flag pure shear (synthetic oblique, positive determinant)", () => { + // Surgical path preserves the shear on survivors; re-emit would drop it. + expect(rotationFromMatrix({ a: 1, b: 0, c: 0.3, d: 1 })).toBeUndefined(); + }); + + it("returns undefined for a degenerate zero matrix", () => { + expect(rotationFromMatrix({ a: 0, b: 0 })).toBeUndefined(); + }); +}); + +describe("counterPageRotation", () => { + it("is undefined for an unrotated page", () => { + expect(counterPageRotation(0)).toBeUndefined(); + expect(counterPageRotation(4)).toBeUndefined(); + }); + + it("counter-rotates 90/180/270 so new text reads upright", () => { + expect(counterPageRotation(1)).toEqual({ cos: 0, sin: 1 }); // +90 CCW + expect(counterPageRotation(2)).toEqual({ cos: -1, sin: 0 }); // 180 + expect(counterPageRotation(3)).toEqual({ cos: 0, sin: -1 }); // -90 + }); + + it("normalises out-of-range / negative quarter-turns", () => { + expect(counterPageRotation(5)).toEqual({ cos: 0, sin: 1 }); // 5 % 4 == 1 + expect(counterPageRotation(-3)).toEqual({ cos: 0, sin: 1 }); // -3 -> 1 + }); +}); diff --git a/frontend/editor/src/core/tools/pdfTextEditor/__tests__/sha256.test.ts b/frontend/editor/src/core/tools/pdfTextEditor/__tests__/sha256.test.ts new file mode 100644 index 0000000000..18cd97de64 --- /dev/null +++ b/frontend/editor/src/core/tools/pdfTextEditor/__tests__/sha256.test.ts @@ -0,0 +1,40 @@ +import { describe, expect, it } from "vitest"; +import { createHash } from "node:crypto"; + +import { sha256Hex } from "@app/tools/pdfTextEditor/util/sha256"; + +// The pure-JS SHA-256 fingerprints embedded font programs so the backend can +// match the EXACT subset font a charcode request targets. +describe("sha256Hex", () => { + it("matches the FIPS 180-4 vectors", () => { + expect(sha256Hex(new Uint8Array(0))).toBe( + "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + ); + expect(sha256Hex(new TextEncoder().encode("abc"))).toBe( + "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad", + ); + expect( + sha256Hex( + new TextEncoder().encode( + "abcdbcdecdefdefgefghfghighijhijkijkljklmklmnlmnomnopnopq", + ), + ), + ).toBe("248d6a61d20638b8e5c026930c3e6039a33ce45964ff2167f6ecedd419db06c1"); + }); + + it("agrees with node:crypto across block-boundary and large inputs", () => { + // Deterministic pseudo-random bytes; lengths straddle the 64-byte block + // size plus a large buffer like a real font program. + const lengths = [1, 55, 56, 63, 64, 65, 127, 128, 1000, 70_000]; + for (const len of lengths) { + const data = new Uint8Array(len); + let seed = 0x12345678 ^ len; + for (let i = 0; i < len; i++) { + seed = (seed * 1103515245 + 12345) >>> 0; + data[i] = seed & 0xff; + } + const expected = createHash("sha256").update(data).digest("hex"); + expect(sha256Hex(data), `length ${len}`).toBe(expected); + } + }); +}); diff --git a/frontend/editor/src/core/tools/pdfTextEditor/__tests__/spellcheck.test.ts b/frontend/editor/src/core/tools/pdfTextEditor/__tests__/spellcheck.test.ts new file mode 100644 index 0000000000..302923a436 --- /dev/null +++ b/frontend/editor/src/core/tools/pdfTextEditor/__tests__/spellcheck.test.ts @@ -0,0 +1,226 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"; +import { + DEFAULT_SPELLCHECK_PREFERENCE, + SPELLCHECK_AUTO, + SPELLCHECK_LANGUAGES, + __resetSpellcheckForTests, + getSpellcheckPreference, + resolveLang, + setSpellcheckEnabled, + setSpellcheckLang, + setSpellcheckPreference, + subscribeSpellcheck, +} from "@app/tools/pdfTextEditor/util/spellcheck"; + +const STORAGE_KEY = "stirling.pdfTextEditor.spellcheck"; + +const realStorage = window.localStorage; + +function setStorage(value: Storage | undefined): void { + (window as unknown as { localStorage: Storage | undefined }).localStorage = + value; +} + +function throwingStorage(): Storage { + const boom = () => { + throw new Error("localStorage is blocked"); + }; + return { + get length(): number { + return 0; + }, + clear: boom, + getItem: boom, + key: boom, + removeItem: boom, + setItem: boom, + } as unknown as Storage; +} + +beforeEach(() => { + setStorage(realStorage); + realStorage.clear(); + __resetSpellcheckForTests(); +}); + +afterEach(() => { + setStorage(realStorage); + realStorage.clear(); + __resetSpellcheckForTests(); +}); + +describe("spellcheck default state", () => { + it("is off and automatic with nothing persisted", () => { + expect(getSpellcheckPreference()).toEqual({ + enabled: false, + lang: SPELLCHECK_AUTO, + }); + }); + + it("returns a stable snapshot reference until it changes", () => { + const first = getSpellcheckPreference(); + expect(getSpellcheckPreference()).toBe(first); + setSpellcheckEnabled(true); + expect(getSpellcheckPreference()).not.toBe(first); + }); + + it("offers en-US, en-GB and an RTL plus an Indic language", () => { + const tags = SPELLCHECK_LANGUAGES.map((l) => l.tag); + expect(tags).toEqual( + expect.arrayContaining(["en-US", "en-GB", "de", "fr", "es", "ar", "hi"]), + ); + expect(tags).not.toContain(SPELLCHECK_AUTO); + }); + + it("exposes a frozen default so callers cannot mutate it", () => { + expect(Object.isFrozen(DEFAULT_SPELLCHECK_PREFERENCE)).toBe(true); + }); +}); + +describe("spellcheck persistence", () => { + it("round-trips through localStorage", () => { + setSpellcheckEnabled(true); + setSpellcheckLang("de"); + expect(JSON.parse(realStorage.getItem(STORAGE_KEY) ?? "null")).toEqual({ + enabled: true, + lang: "de", + }); + __resetSpellcheckForTests(); + expect(getSpellcheckPreference()).toEqual({ enabled: true, lang: "de" }); + }); + + it("trims a padded tag and treats a blank one as automatic", () => { + setSpellcheckLang(" fr "); + expect(getSpellcheckPreference().lang).toBe("fr"); + setSpellcheckLang(" "); + expect(getSpellcheckPreference().lang).toBe(SPELLCHECK_AUTO); + }); + + it("falls back to the default when the stored JSON is corrupt", () => { + realStorage.setItem(STORAGE_KEY, "{not json"); + __resetSpellcheckForTests(); + expect(getSpellcheckPreference()).toEqual({ + enabled: false, + lang: SPELLCHECK_AUTO, + }); + }); + + it("ignores a stored value that is not an object", () => { + realStorage.setItem(STORAGE_KEY, '"enabled"'); + __resetSpellcheckForTests(); + expect(getSpellcheckPreference().enabled).toBe(false); + }); + + it("keeps the valid half of a partially wrong-typed entry", () => { + realStorage.setItem(STORAGE_KEY, '{"enabled":true,"lang":7}'); + __resetSpellcheckForTests(); + expect(getSpellcheckPreference()).toEqual({ + enabled: true, + lang: SPELLCHECK_AUTO, + }); + }); + + it("degrades to memory when localStorage throws", () => { + setStorage(throwingStorage()); + __resetSpellcheckForTests(); + expect(getSpellcheckPreference().enabled).toBe(false); + expect(() => setSpellcheckEnabled(true)).not.toThrow(); + expect(getSpellcheckPreference().enabled).toBe(true); + }); + + it("degrades to memory when localStorage is absent", () => { + setStorage(undefined); + __resetSpellcheckForTests(); + expect(getSpellcheckPreference()).toEqual({ + enabled: false, + lang: SPELLCHECK_AUTO, + }); + expect(() => setSpellcheckLang("es")).not.toThrow(); + expect(getSpellcheckPreference().lang).toBe("es"); + }); +}); + +describe("spellcheck subscribers", () => { + it("notifies on change and stops after unsubscribe", () => { + const seen: boolean[] = []; + const unsubscribe = subscribeSpellcheck((p) => seen.push(p.enabled)); + setSpellcheckEnabled(true); + unsubscribe(); + setSpellcheckEnabled(false); + expect(seen).toEqual([true]); + }); + + it("does not notify when the value is unchanged", () => { + const listener = vi.fn(); + subscribeSpellcheck(listener); + setSpellcheckPreference({ enabled: false, lang: SPELLCHECK_AUTO }); + expect(listener).not.toHaveBeenCalled(); + setSpellcheckPreference({ enabled: true, lang: SPELLCHECK_AUTO }); + expect(listener).toHaveBeenCalledTimes(1); + }); + + it("keeps notifying after one listener throws", () => { + const later = vi.fn(); + subscribeSpellcheck(() => { + throw new Error("listener blew up"); + }); + subscribeSpellcheck(later); + setSpellcheckEnabled(true); + expect(later).toHaveBeenCalledWith({ + enabled: true, + lang: SPELLCHECK_AUTO, + }); + }); + + it("survives a listener that unsubscribes another mid-notify", () => { + const later = vi.fn(); + let unsubscribeLater = () => {}; + subscribeSpellcheck(() => unsubscribeLater()); + unsubscribeLater = subscribeSpellcheck(later); + setSpellcheckEnabled(true); + expect(later).toHaveBeenCalledTimes(1); + setSpellcheckEnabled(false); + expect(later).toHaveBeenCalledTimes(1); + }); +}); + +describe("resolveLang", () => { + it("returns null when spell-check is off", () => { + expect(resolveLang({ enabled: false, lang: "de" }, "fr")).toBeNull(); + }); + + it("returns the explicit tag when one is chosen", () => { + expect(resolveLang({ enabled: true, lang: "en-GB" }, "fr")).toBe("en-GB"); + }); + + it("trims an explicit tag", () => { + expect(resolveLang({ enabled: true, lang: " pt-BR " }, null)).toBe("pt-BR"); + }); + + it("rejects an explicit tag that is not BCP-47 shaped", () => { + expect(resolveLang({ enabled: true, lang: "not a tag" }, "fr")).toBeNull(); + }); + + it("falls back to the document language on auto", () => { + expect(resolveLang({ enabled: true, lang: SPELLCHECK_AUTO }, "hi")).toBe( + "hi", + ); + }); + + it("returns null on auto with no usable document language", () => { + const pref = { enabled: true, lang: SPELLCHECK_AUTO }; + expect(resolveLang(pref, null)).toBeNull(); + expect(resolveLang(pref, undefined)).toBeNull(); + expect(resolveLang(pref, "")).toBeNull(); + expect(resolveLang(pref, " ")).toBeNull(); + expect(resolveLang(pref, "en_US")).toBeNull(); + }); + + it("accepts every offered language", () => { + for (const lang of SPELLCHECK_LANGUAGES) { + expect(resolveLang({ enabled: true, lang: lang.tag }, null)).toBe( + lang.tag, + ); + } + }); +}); diff --git a/frontend/editor/src/core/tools/pdfTextEditor/__tests__/textMatching.test.ts b/frontend/editor/src/core/tools/pdfTextEditor/__tests__/textMatching.test.ts new file mode 100644 index 0000000000..fd811bdbac --- /dev/null +++ b/frontend/editor/src/core/tools/pdfTextEditor/__tests__/textMatching.test.ts @@ -0,0 +1,270 @@ +import { describe, it, expect } from "vitest"; +import { + findMatches, + foldForSearch, + isWordChar, + replaceMatch, + replaceMatches, +} from "@app/tools/pdfTextEditor/util/textMatching"; +import type { TextMatch } from "@app/tools/pdfTextEditor/util/textMatching"; + +const RESUME_ACCENTED = "résumé"; +const RESUME_DECOMPOSED = "résumé"; +const CAFE_DECOMPOSED = "café"; + +function slices(haystack: string, matches: TextMatch[]): string[] { + return matches.map((m) => haystack.slice(m.start, m.end)); +} + +describe("findMatches degenerate input", () => { + it("returns no matches for an empty needle", () => { + expect(findMatches("hello world", "")).toEqual([]); + expect(findMatches("", "")).toEqual([]); + }); + + it("returns no matches for an empty haystack", () => { + expect(findMatches("", "a")).toEqual([]); + }); + + it("returns no matches when the needle is longer than the haystack", () => { + expect(findMatches("abc", "abcd")).toEqual([]); + expect(findMatches("abc", "abcd", { ignoreAccents: true })).toEqual([]); + }); +}); + +describe("findMatches case handling", () => { + it("is case-insensitive by default", () => { + expect(findMatches("Foo foo FOO", "foo")).toEqual([ + { start: 0, end: 3 }, + { start: 4, end: 7 }, + { start: 8, end: 11 }, + ]); + }); + + it("honours matchCase", () => { + expect(findMatches("Foo foo FOO", "foo", { matchCase: true })).toEqual([ + { start: 4, end: 7 }, + ]); + }); + + it("case-folds non-ASCII letters", () => { + expect(findMatches("ÉCOLE", "école")).toEqual([{ start: 0, end: 5 }]); + expect(findMatches("ÉCOLE", "école", { matchCase: true })).toEqual([]); + }); +}); + +describe("findMatches overlapping candidates", () => { + it("returns non-overlapping matches, scanning left to right", () => { + expect(findMatches("aaaa", "aa")).toEqual([ + { start: 0, end: 2 }, + { start: 2, end: 4 }, + ]); + expect(findMatches("aaa", "aa")).toEqual([{ start: 0, end: 2 }]); + }); + + it("does not lose a later match when an earlier candidate is rejected", () => { + expect(findMatches("abcab ab", "ab", { wholeWord: true })).toEqual([ + { start: 6, end: 8 }, + ]); + }); +}); + +describe("findMatches accent folding", () => { + const hay = `Le ${RESUME_ACCENTED} final`; + + it("matches accented text against unaccented input when enabled", () => { + const found = findMatches(hay, "resume", { ignoreAccents: true }); + expect(found).toEqual([{ start: 3, end: 9 }]); + expect(slices(hay, found)).toEqual([RESUME_ACCENTED]); + }); + + it("does not match accented text when the flag is off", () => { + expect(findMatches(hay, "resume")).toEqual([]); + }); + + it("folds the needle as well as the haystack", () => { + expect( + findMatches("the resume", RESUME_ACCENTED, { ignoreAccents: true }), + ).toEqual([{ start: 4, end: 10 }]); + }); + + it("does not attempt non-diacritic folding such as sharp s", () => { + expect(findMatches("Straße", "Strasse", { ignoreAccents: true })).toEqual( + [], + ); + }); + + it("leaves standalone combining marks alone, so decomposed text is not folded", () => { + // Dropping the mark would shift every later offset, so length stability wins. + expect(RESUME_DECOMPOSED).toHaveLength(8); + expect( + findMatches(RESUME_DECOMPOSED, "resume", { ignoreAccents: true }), + ).toEqual([]); + }); +}); + +describe("foldForSearch offset stability", () => { + const mixed = `Élan \u{1f600} naïve İstanbul ${RESUME_ACCENTED} 中文 ẞ_1`; + + it("keeps the folded length identical to the original", () => { + for (const opts of [ + {}, + { matchCase: true }, + { ignoreAccents: true }, + { matchCase: true, ignoreAccents: true }, + ]) { + expect(foldForSearch(mixed, opts)).toHaveLength(mixed.length); + } + }); + + it("maps a folded index back to the identical index in the original", () => { + const folded = foldForSearch(mixed, { ignoreAccents: true }); + const at = folded.indexOf("naive"); + expect(at).toBeGreaterThan(-1); + expect(mixed.slice(at, at + 5)).toBe("naïve"); + }); + + it("reports offsets that slice the original text back out", () => { + const found = findMatches(mixed, "resume", { ignoreAccents: true }); + expect(slices(mixed, found)).toEqual([RESUME_ACCENTED]); + }); +}); + +describe("findMatches whole word", () => { + it("matches at the very start and end of the string", () => { + expect(findMatches("cat", "cat", { wholeWord: true })).toEqual([ + { start: 0, end: 3 }, + ]); + expect(findMatches("a cat", "cat", { wholeWord: true })).toEqual([ + { start: 2, end: 5 }, + ]); + expect(findMatches("cat nap", "cat", { wholeWord: true })).toEqual([ + { start: 0, end: 3 }, + ]); + }); + + it("rejects a match glued to other word characters", () => { + expect(findMatches("concatenate", "cat", { wholeWord: true })).toEqual([]); + expect(findMatches("cat5", "cat", { wholeWord: true })).toEqual([]); + expect(findMatches("cat_", "cat", { wholeWord: true })).toEqual([]); + expect(findMatches("_cat", "cat", { wholeWord: true })).toEqual([]); + }); + + it("accepts punctuation and whitespace as boundaries", () => { + expect(findMatches("(cat), cat.", "cat", { wholeWord: true })).toEqual([ + { start: 1, end: 4 }, + { start: 7, end: 10 }, + ]); + }); + + it("treats non-ASCII letters as word characters, unlike ASCII regex breaks", () => { + expect(findMatches("Straße", "stra", { wholeWord: true })).toEqual([]); + expect(findMatches("naïve", "na", { wholeWord: true })).toEqual([]); + expect(findMatches(`un café.`, "café", { wholeWord: true })).toEqual([ + { start: 3, end: 7 }, + ]); + }); + + it("treats a trailing combining mark as a word character", () => { + expect(findMatches(CAFE_DECOMPOSED, "cafe", { wholeWord: true })).toEqual( + [], + ); + }); + + it("combines with accent folding", () => { + expect( + findMatches("un café.", "cafe", { + wholeWord: true, + ignoreAccents: true, + }), + ).toEqual([{ start: 3, end: 7 }]); + }); + + it("classifies word characters Unicode-aware", () => { + expect(isWordChar("ß")).toBe(true); + expect(isWordChar("中")).toBe(true); + expect(isWordChar("٣")).toBe(true); + expect(isWordChar("_")).toBe(true); + expect(isWordChar("́")).toBe(true); + expect(isWordChar(" ")).toBe(false); + expect(isWordChar("-")).toBe(false); + expect(isWordChar("")).toBe(false); + expect(isWordChar(null)).toBe(false); + }); +}); + +// CJK ideographs are letters and are not space-delimited, so whole-word only +// matches a run bounded by punctuation or spaces. +describe("findMatches with CJK", () => { + it("matches freely when whole word is off", () => { + expect(findMatches("中文文档", "文")).toEqual([ + { start: 1, end: 2 }, + { start: 2, end: 3 }, + ]); + }); + + it("finds nothing mid-phrase when whole word is on", () => { + expect(findMatches("中文文档", "文", { wholeWord: true })).toEqual([]); + }); + + it("matches a delimited CJK phrase when whole word is on", () => { + expect( + findMatches("「中文」と", "中文", { + wholeWord: true, + }), + ).toEqual([{ start: 1, end: 3 }]); + }); +}); + +describe("findMatches with astral characters", () => { + it("does not split a surrogate pair when checking word boundaries", () => { + expect( + findMatches("\u{1f600}cat\u{1f600}", "cat", { wholeWord: true }), + ).toEqual([{ start: 2, end: 5 }]); + }); +}); + +describe("replaceMatch", () => { + it("splices the replacement literally", () => { + expect(replaceMatch("hello world", { start: 6, end: 11 }, "there")).toBe( + "hello there", + ); + }); + + it("never interprets $ sequences as regex references", () => { + expect(replaceMatch("say foo", { start: 4, end: 7 }, "$&")).toBe("say $&"); + expect(replaceMatch("say foo", { start: 4, end: 7 }, "$1$$$'")).toBe( + "say $1$$$'", + ); + }); + + it("supports deletion and guards out-of-range offsets", () => { + expect(replaceMatch("abcd", { start: 1, end: 3 }, "")).toBe("ad"); + expect(replaceMatch("abcd", { start: 2, end: 9 }, "x")).toBe("abcd"); + expect(replaceMatch("abcd", { start: 3, end: 1 }, "x")).toBe("abcd"); + }); +}); + +describe("replaceMatches", () => { + it("rewrites every match in one pass", () => { + const hay = "Foo foo FOO"; + expect(replaceMatches(hay, findMatches(hay, "foo"), "bar")).toBe( + "bar bar bar", + ); + }); + + it("returns the text unchanged when there are no matches", () => { + expect(replaceMatches("abc", [], "x")).toBe("abc"); + }); + + it("keeps replacement text literal", () => { + const hay = "a b a"; + expect(replaceMatches(hay, findMatches(hay, "a"), "$&")).toBe("$& b $&"); + }); + + it("preserves accented context around folded matches", () => { + const hay = `Le ${RESUME_ACCENTED} final`; + const found = findMatches(hay, "resume", { ignoreAccents: true }); + expect(replaceMatches(hay, found, "summary")).toBe("Le summary final"); + }); +}); diff --git a/frontend/editor/src/core/tools/pdfTextEditor/__tests__/toolbarState.test.ts b/frontend/editor/src/core/tools/pdfTextEditor/__tests__/toolbarState.test.ts new file mode 100644 index 0000000000..e30f585e1e --- /dev/null +++ b/frontend/editor/src/core/tools/pdfTextEditor/__tests__/toolbarState.test.ts @@ -0,0 +1,54 @@ +import { describe, it, expect } from "vitest"; +import { deriveToolbarState } from "@app/tools/pdfTextEditor/util/toolbarState"; +import type { + PageSnapshot, + SelectionState, +} from "@app/tools/pdfTextEditor/types"; + +function mkRun(id: string, fontId: string, fontSize = 12) { + return { + id, + pageIndex: 0, + bounds: { x: 0, y: 0, width: 10, height: 10 }, + matrix: { a: 1, b: 0, c: 0, d: 1, e: 0, f: 0 }, + text: "x", + fontId, + fontSize, + fill: { r: 0, g: 0, b: 0, a: 255 }, + fontSubset: false, + }; +} +function mkPages(runs: ReturnType[]): PageSnapshot[] { + return [ + { + pageIndex: 0, + width: 100, + height: 100, + revision: 0, + dirty: false, + runs, + images: [], + } as unknown as PageSnapshot, + ]; +} +function mkSel(runIds: string[]): SelectionState { + return { runIds, imageIds: [] } as unknown as SelectionState; +} + +describe("deriveToolbarState mixed.fontFamily", () => { + it("flags fontFamily mixed when selected runs differ", () => { + const s = deriveToolbarState( + mkPages([mkRun("a", "pdf:1:Arial"), mkRun("b", "pdf:2:Times")]), + mkSel(["a", "b"]), + ); + expect(s.mixed.fontFamily).toBe(true); + }); + + it("does not flag fontFamily mixed when fontIds match", () => { + const s = deriveToolbarState( + mkPages([mkRun("a", "pdf:1:Arial"), mkRun("b", "pdf:1:Arial")]), + mkSel(["a", "b"]), + ); + expect(s.mixed.fontFamily).toBe(false); + }); +}); diff --git a/frontend/editor/src/core/tools/pdfTextEditor/charcode/BackendResolver.ts b/frontend/editor/src/core/tools/pdfTextEditor/charcode/BackendResolver.ts new file mode 100644 index 0000000000..9192d7c064 --- /dev/null +++ b/frontend/editor/src/core/tools/pdfTextEditor/charcode/BackendResolver.ts @@ -0,0 +1,842 @@ +import apiClient from "@app/services/apiClient"; +import type { + CharcodeResolver, + CharcodeResolveResult, + ResolverContext, +} from "@app/tools/pdfTextEditor/charcode/CharcodeStrategy"; +import { getActiveCharcodeStrategy } from "@app/tools/pdfTextEditor/charcode/CharcodeStrategy"; +import { getCachedFontProgramSha256 } from "@app/tools/pdfTextEditor/charcode/CmapResolver"; + +/** Strategy 3: ask the Spring backend (PDFBox) to encode chars. */ + +/** Cache: per (fontPtr, char) → charcode integer (or null = missing). */ +const charCache = new Map(); + +// Expiry timestamps for TRANSIENT-failure nulls (network error, backend down, +// serialize hiccup). +const negativeUntil = new Map(); +const NEGATIVE_TTL_MS = 30_000; + +function setTransientNull(key: string): void { + charCache.set(key, null); + negativeUntil.set(key, Date.now() + NEGATIVE_TTL_MS); +} + +/** Track in-flight prefetches so we don't double-fire. */ +const inFlight = new Set(); + +/** Hard cap on CONCURRENT auto-prefetches. */ +const MAX_CONCURRENT_AUTO_PREFETCH = 2; +// Font batches in flight within a single prefetch. Matches the cap +// prewarmPageCharcodes uses so both paths load the backend the same way. +const PREFETCH_BATCH_CONCURRENCY = 6; +let autoPrefetchActive = 0; + +/** Short-lived cache of the serialized document, shared by prefetch bursts. */ +let serializedCache: { bytes: Uint8Array; at: number } | null = null; +const SERIALIZE_TTL_MS = 4000; + +function serializeDocCached( + save: { serialize: (d: D) => Uint8Array }, + doc: D, +): Uint8Array | null { + const now = Date.now(); + if (serializedCache && now - serializedCache.at < SERIALIZE_TTL_MS) { + return serializedCache.bytes; + } + const bytes = save.serialize(doc); + if (!bytes || bytes.byteLength === 0) return null; + serializedCache = { bytes, at: now }; + return bytes; +} + +/** Endpoint config - resolved relative to current origin in dev. */ +const ENDPOINT = "/api/v1/general/pdf-text-editor/encode-charcodes"; + +/** Shape of the encode-charcodes JSON response (mirrors the controller). */ +interface EncodeCharcodesResponse { + charcodes?: number[]; + missing?: string[]; + note?: string; + error?: string; +} + +// POST JSON to the charcode endpoint via the shared `apiClient`. `apiClient` is +// the canonical Stirling HTTP helper. +async function postCharcodes( + body: Record, +): Promise { + try { + const resp = await apiClient.post(ENDPOINT, body, { + suppressErrorToast: true, + skipAuthRedirect: true, + }); + return resp.data ?? null; + } catch { + return null; + } +} + +export class BackendResolver implements CharcodeResolver { + readonly name = "backend" as const; + + resolve( + font: number, + text: string, + ctx: ResolverContext, + ): CharcodeResolveResult | null { + if (!font || !text) return null; + const charcodes: number[] = []; + const missing: string[] = []; + const cacheMisses: string[] = []; + for (const ch of text) { + // Whitespace is never charcode-reused (no real space glyph in subset + // fonts; SetCharcodes(0x20) paints garbage like „). + if (/\s/.test(ch)) { + missing.push(ch); + continue; + } + const key = cacheKey(font, ch); + if (!charCache.has(key)) { + cacheMisses.push(ch); + missing.push(ch); + continue; + } + const code = charCache.get(key); + if (code === null) { + // A transient-failure null past its TTL becomes a cache miss so + // the prefetch below retries it. + const until = negativeUntil.get(key); + if (until !== undefined && Date.now() >= until) { + charCache.delete(key); + negativeUntil.delete(key); + cacheMisses.push(ch); + } + missing.push(ch); + continue; + } + if (typeof code === "number") charcodes.push(code); + } + // Auto-kick a background prefetch for the cache-miss chars so the next time + // the user types them we have charcodes to use. + if (cacheMisses.length > 0) { + maybeAutoPrefetch(font, cacheMisses, ctx); + } + return { + charcodes, + coverage: charcodes.length, + missing, + note: + cacheMisses.length > 0 + ? `backend cache miss for ${JSON.stringify(cacheMisses.join(""))} - prefetch kicked off in background, retry the keystroke in a moment` + : `backend cache served ${charcodes.length} of ${text.length} char(s)`, + }; + } +} + +// Fire-and-forget prefetch triggered from inside `resolve()` when the cache +// doesn't yet have the chars the user just typed. +function maybeAutoPrefetch( + fontPtr: number, + chars: string[], + ctx: ResolverContext, +): void { + // Never round-trip whitespace - it has no reusable glyph (see resolve()). + // Dedupe too: resolve() pushes one entry per occurrence, so a repeated + // character would otherwise cost one request per repeat. + chars = [...new Set(chars.filter((ch) => !/\s/.test(ch)))]; + if (chars.length === 0) return; + // Concurrency cap: dropping is safe - the chars stay cache-miss and a + // later keystroke re-fires once a slot frees up. + if (autoPrefetchActive >= MAX_CONCURRENT_AUTO_PREFETCH) return; + // Avoid re-firing while a prefetch for these chars is in flight. + const reqKey = `auto:${fontPtr}:${chars.join("")}`; + if (inFlight.has(reqKey)) return; + inFlight.add(reqKey); + autoPrefetchActive += 1; + void (async () => { + try { + const { PdfiumSave } = + await import("@app/tools/pdfTextEditor/pdfium/PdfiumSave"); + const doc = getEditorDocument(); + if (!doc) { + if (typeof console !== "undefined") { + console.warn( + "[charcode] backend auto-prefetch: editor document unavailable", + ); + } + for (const ch of chars) setTransientNull(cacheKey(fontPtr, ch)); + return; + } + const bytes = serializeDocCached(PdfiumSave, doc); + if (!bytes) { + for (const ch of chars) setTransientNull(cacheKey(fontPtr, ch)); + return; + } + const pdfBase64 = uint8ToBase64(bytes); + const pageIdx = pageIdxOfPagePtr(ctx); + + // Batch by font: one request per font carrying all of that font's + // missing chars, mirroring prewarmPageCharcodes. Previously this fired + // one request per character, each re-sending the entire base64 PDF. + const byFont = new Map(); + for (const ch of chars) { + const perCharFont = findFontForChar(ch, ctx) || fontPtr; + const arr = byFont.get(perCharFont); + if (arr) arr.push(ch); + else byFont.set(perCharFont, [ch]); + } + + const batches = [...byFont.entries()]; + let batchIdx = 0; + const workers: Promise[] = []; + for ( + let w = 0; + w < Math.min(PREFETCH_BATCH_CONCURRENCY, batches.length); + w++ + ) { + workers.push( + (async () => { + while (true) { + const me = batchIdx++; + if (me >= batches.length) return; + const [perCharFont, fontChars] = batches[me]; + const json = await postCharcodes({ + pdfBase64, + pageIndex: pageIdx >= 0 ? pageIdx : 0, + // Any of this font's chars is a valid locator. + locatorChar: fontChars[0], + fontName: readFontName(ctx.module, perCharFont), + // Program-bytes hash: the only identity that survives PDFium's + // subset-tag stripping. + fontSha256: + getCachedFontProgramSha256(perCharFont) ?? undefined, + text: fontChars.join(""), + }); + + if (!json || json.error) { + // Network failure / backend error: retry after the TTL. Only a + // real "encoded 0 of N" answer is a permanent miss. + for (const ch of fontChars) { + setTransientNull(cacheKey(perCharFont, ch)); + } + } else { + // The backend appends one charcode per NON-missing char, in + // request order. + const missing = new Set(json.missing ?? []); + const codes = json.charcodes ?? []; + let k = 0; + for (const ch of fontChars) { + if (missing.has(ch)) { + charCache.set(cacheKey(perCharFont, ch), null); + continue; + } + const code = codes[k++]; + charCache.set( + cacheKey(perCharFont, ch), + typeof code === "number" ? code : null, + ); + } + } + + // Stop the per-keystroke prefetch storm. resolve looks these + // chars up under the QUERIED font, not perCharFont. Use the + // TTL'd null: this font was never actually asked, so a permanent + // null would kill the pair for the rest of the session. + if (perCharFont !== fontPtr) { + for (const ch of fontChars) { + setTransientNull(cacheKey(fontPtr, ch)); + } + } + } + })(), + ); + } + await Promise.all(workers); + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + if (typeof console !== "undefined") { + console.warn("[charcode] backend prefetch threw:", err); + } + // Negative-cache with TTL so we don't retry the same chars in a tight + // loop but DO recover once the backend is reachable again. + for (const ch of chars) setTransientNull(cacheKey(fontPtr, ch)); + // Lazy-import charcodeRegistry to avoid the cyclic + // BackendResolver ↔ charcodeRegistry module init. + try { + const { emitCharcodeEvent } = + await import("@app/tools/pdfTextEditor/charcode/charcodeRegistry"); + emitCharcodeEvent({ + strategy: getActiveCharcodeStrategy(), + text: chars.join(""), + fontPtr, + resolved: [], + missing: [...chars], + note: `backend prefetch threw: ${msg}`, + outcome: "partial-coverage-fallback", + }); + } catch { + /* registry import itself failed - already logged above */ + } + } finally { + inFlight.delete(reqKey); + autoPrefetchActive -= 1; + } + })(); +} + +interface TextPageModule { + FPDFText_LoadPage?: (page: number) => number; + FPDFText_ClosePage?: (textPage: number) => void; + FPDFText_CountChars?: (textPage: number) => number; + FPDFText_GetUnicode?: (textPage: number, idx: number) => number; + FPDFText_GetTextObject?: (textPage: number, idx: number) => number; +} + +interface FontReadModule { + FPDFTextObj_GetFont?: (obj: number) => number; +} + +// Find an existing char on the current page whose text object uses the given +// font. +const fontForCharCache = new Map(); + +/** Bold/italic classification of a font, read from its /BaseFont name. */ +export interface FontStyleClass { + bold: boolean; + italic: boolean; +} + +/** + * Classify a font handle as bold/italic from its /BaseFont name. + * + * Borrowing a glyph from a face of a different weight is what made edited body + * text come back bold: the first "o" in document order often lives in a bold + * heading. + */ +export function fontStyleClass( + m: ResolverContext["module"], + fontPtr: number, +): FontStyleClass | null { + const name = readFontName(m, fontPtr); + if (!name) return null; + return styleClassFromName(name); +} + +/** Same classification from a font FAMILY name (base-14 or device font). */ +export function styleClassFromName(name: string): FontStyleClass { + return { + bold: /bold|black|heavy|semibold|demi/i.test(name), + italic: /italic|oblique/i.test(name), + }; +} + +const reusableFontCache = new Map(); + +/** + * Whether a font has a real font program behind it. + * + * A Type 3 face is a dictionary of content-stream procedures, so PDFium can + * report neither a glyph advance nor a usable ink box for it. Its glyphs are + * still drawable - callers may reuse one when they can measure its advance + * some other way - but laying out new text on PDFium's numbers alone stacks + * every glyph on the previous one. + */ +export function fontIsReusable( + m: ResolverContext["module"], + fontPtr: number, +): boolean { + if (!fontPtr) return false; + const cached = reusableFontCache.get(fontPtr); + if (cached !== undefined) return cached; + const getData = ( + m as unknown as { + FPDFFont_GetFontData?: ( + font: number, + buf: number, + buflen: number, + outLen: number, + ) => boolean; + } + ).FPDFFont_GetFontData; + // No API to ask with: assume reusable so nothing regresses. + if (typeof getData !== "function") { + reusableFontCache.set(fontPtr, true); + return true; + } + // A Type 3 font is a dictionary of content-stream procedures, not a font + // program. PDFium still answers "true" for it, but reports a length of 0 - + // the length is the part that distinguishes a real face. + let ok = false; + const out = m.pdfium.wasmExports.malloc(4); + try { + m.pdfium.setValue(out, 0, "i32"); + ok = getData(fontPtr, 0, 0, out) && m.pdfium.getValue(out, "i32") > 0; + } catch { + ok = false; + } finally { + m.pdfium.wasmExports.free(out); + } + reusableFontCache.set(fontPtr, ok); + return ok; +} + +/** Test-only: clear the reusable-font cache. */ +export function _clearReusableFontCacheForTests(): void { + reusableFontCache.clear(); +} + +export function findFontForChar( + unicodeChar: string, + ctx: ResolverContext, + // When given, only fonts with the SAME bold/italic class as this one are + // accepted, so a borrowed glyph never changes the run's weight or slant. + likeFontPtr?: number, + // Used when there is no source font handle to read a style from - notably on + // the undo path, which re-emits with `originalFontPtr: 0`. Without it the + // borrow is unconstrained again and restored body text comes back bold. + likeStyle?: FontStyleClass | null, +): number | null { + if (!unicodeChar) return null; + const cp = unicodeChar.codePointAt(0); + if (cp === undefined) return null; + const m = ctx.module; + const want = + (likeFontPtr ? fontStyleClass(m, likeFontPtr) : null) ?? likeStyle ?? null; + // The style is part of the answer, so it must be part of the cache key. + const styleK = want + ? `${want.bold ? "b" : ""}${want.italic ? "i" : ""}|` + : ""; + // So is the source face: the borrow prefers the run's own family, so two + // runs of different families must not share an answer. + const likeName = likeFontPtr + ? baseFontFamily(readFontName(m, likeFontPtr)) + : undefined; + const cacheK = `${ctx.pagePtr}:${styleK}${likeName ?? ""}|${cp}`; + if (fontForCharCache.has(cacheK)) return fontForCharCache.get(cacheK) ?? null; + const tpMod = m as unknown as TextPageModule; + const fontMod = m as unknown as FontReadModule; + if ( + !tpMod.FPDFText_LoadPage || + !tpMod.FPDFText_CountChars || + !tpMod.FPDFText_GetUnicode || + !tpMod.FPDFText_GetTextObject || + !fontMod.FPDFTextObj_GetFont + ) { + fontForCharCache.set(cacheK, null); + return null; + } + const textPage = tpMod.FPDFText_LoadPage(ctx.pagePtr); + if (!textPage) { + fontForCharCache.set(cacheK, null); + return null; + } + try { + const count = tpMod.FPDFText_CountChars(textPage); + // The run's OWN family, wherever the page happens to draw this char in it, + // beats whichever style-compatible face comes first in content order. A + // word the document already uses otherwise came back in a near-miss face - + // right weight, slightly wrong shapes and advances. + let fallback: number | null = null; + for (let i = 0; i < count; i++) { + const u = tpMod.FPDFText_GetUnicode(textPage, i); + if (u !== cp) continue; + const obj = tpMod.FPDFText_GetTextObject(textPage, i); + if (!obj) continue; + try { + const f = fontMod.FPDFTextObj_GetFont(obj); + if (!f) continue; + if (want) { + const got = fontStyleClass(m, f); + // An unnamed font can't be vouched for; skip it rather than risk a + // weight change. + if (!got || got.bold !== want.bold || got.italic !== want.italic) { + continue; + } + } + if (!likeName || baseFontFamily(readFontName(m, f)) === likeName) { + fontForCharCache.set(cacheK, f); + return f; + } + if (fallback === null) fallback = f; + } catch { + continue; + } + } + if (fallback !== null) { + fontForCharCache.set(cacheK, fallback); + return fallback; + } + } finally { + if (tpMod.FPDFText_ClosePage) { + try { + tpMod.FPDFText_ClosePage(textPage); + } catch { + /* best-effort */ + } + } + } + fontForCharCache.set(cacheK, null); + return null; +} + +/** Test-only: clear the per-char-font cache. */ +export function _clearFontForCharCacheForTests(): void { + fontForCharCache.clear(); +} + +interface FontNameModule { + FPDFFont_GetBaseFontName?: (font: number, buf: number, len: number) => number; +} + +/** + * A face's family, with the subset tag and style suffix stripped: + * "ABCDEF+LMRoman12-Regular" -> "lmroman12". Two handles that agree here are + * the same design, so a glyph borrowed across them keeps the run's look. + */ +function baseFontFamily(name: string | undefined): string | undefined { + if (!name) return undefined; + const family = name.replace(/^[A-Z]{6}\+/, "").split(/[-,]/)[0]; + return family ? family.toLowerCase() : undefined; +} + +const fontNameCache = new Map(); + +/** Test-only: clear the memoised /BaseFont names. */ +export function _clearFontNameCacheForTests(): void { + fontNameCache.clear(); +} + +// Read a font's /BaseFont name so the backend can disambiguate WHICH font to +// encode against when two fonts on the page render the same char. +function readFontName( + m: ResolverContext["module"], + fontPtr: number, +): string | undefined { + if (!fontPtr) return undefined; + if (fontNameCache.has(fontPtr)) return fontNameCache.get(fontPtr); + const name = loadFontName(m, fontPtr); + fontNameCache.set(fontPtr, name); + return name; +} + +function loadFontName( + m: ResolverContext["module"], + fontPtr: number, +): string | undefined { + const fn = (m as unknown as FontNameModule).FPDFFont_GetBaseFontName; + if (typeof fn !== "function") return undefined; + try { + const len = fn(fontPtr, 0, 0); + if (len <= 1) return undefined; + const buf = m.pdfium.wasmExports.malloc(len); + try { + fn(fontPtr, buf, len); + return m.pdfium.UTF8ToString(buf) || undefined; + } finally { + m.pdfium.wasmExports.free(buf); + } + } catch { + return undefined; + } +} + +/** Per-page idempotency guard for `prewarmBackendCacheForPage`. */ +const prewarmedPages = new Set(); + +// Pre-warm the backend cache for every Unicode char that already lives on the +// given page. +const TYPEABLE_CHARS: string[] = (() => { + const out: string[] = []; + for (let cp = 0x21; cp <= 0x7e; cp += 1) out.push(String.fromCodePoint(cp)); + return out; +})(); + +const MAX_PREWARM_PROBES = 4000; + +function addTypeableProbes( + probes: Array<{ ch: string; perCharFont: number }>, + seen: Set, +): void { + const fonts = [...new Set(probes.map((p) => p.perCharFont))]; + for (const font of fonts) { + for (const ch of TYPEABLE_CHARS) { + if (probes.length >= MAX_PREWARM_PROBES) return; + const key = `${font}:${ch}`; + if (seen.has(key)) continue; + seen.add(key); + if (charCache.has(cacheKey(font, ch))) continue; + probes.push({ ch, perCharFont: font }); + } + } +} + +export async function prewarmBackendCacheForPage( + pageIndex: number, +): Promise { + // Always log entry so tests + debug have a single signal that "prewarm was at + // least invoked for page N" regardless of which early-return path the body. + if (typeof console !== "undefined") { + console.debug(`[charcode] backend prewarm-start pageIdx=${pageIndex}`); + } + const editorCtx = getEditorContextForPage(pageIndex); + if (!editorCtx) { + if (typeof console !== "undefined") { + console.debug( + `[charcode] backend prewarm pageIdx=${pageIndex} probes=0 (no-editor-ctx)`, + ); + } + return; + } + const { module: m, pagePtr } = editorCtx; + if (prewarmedPages.has(pagePtr)) { + if (typeof console !== "undefined") { + console.debug( + `[charcode] backend prewarm pageIdx=${pageIndex} probes=0 (already-prewarmed)`, + ); + } + return; + } + + // Walk the page text once, collecting (perCharFont, unicode) for every + // glyph. Dedupe so each (font, char) probe fires at most once per page. + const tpMod = m as unknown as TextPageModule; + const fontMod = m as unknown as FontReadModule; + if ( + !tpMod.FPDFText_LoadPage || + !tpMod.FPDFText_CountChars || + !tpMod.FPDFText_GetUnicode || + !tpMod.FPDFText_GetTextObject || + !fontMod.FPDFTextObj_GetFont + ) + return; + + const probes: Array<{ ch: string; perCharFont: number }> = []; + const seen = new Set(); + const textPage = tpMod.FPDFText_LoadPage(pagePtr); + if (!textPage) return; + try { + const count = tpMod.FPDFText_CountChars(textPage); + for (let i = 0; i < count; i++) { + const cp = tpMod.FPDFText_GetUnicode(textPage, i); + if (!cp) continue; + const ch = String.fromCodePoint(cp); + const obj = tpMod.FPDFText_GetTextObject(textPage, i); + if (!obj) continue; + let f = 0; + try { + f = fontMod.FPDFTextObj_GetFont(obj); + } catch { + continue; + } + if (!f) continue; + const key = `${f}:${ch}`; + if (seen.has(key)) continue; + seen.add(key); + // Skip whitespace - those aren't worth round-tripping and + // editTextHelpers' per-char branch bails on whitespace anyway. + if (/\s/.test(ch)) continue; + // Skip if already cached under this perChar font. + if (charCache.has(cacheKey(f, ch))) continue; + probes.push({ ch, perCharFont: f }); + // Seed findFontForChar's cache so the emit-path probe doesn't + // re-walk the text page for the same char. + fontForCharCache.set(`${pagePtr}:${cp}`, f); + } + } finally { + if (tpMod.FPDFText_ClosePage) { + try { + tpMod.FPDFText_ClosePage(textPage); + } catch { + /* best-effort */ + } + } + } + addTypeableProbes(probes, seen); + if (probes.length === 0) return; + + // Guard the page only once we're committed to the fetch fan-out. + prewarmedPages.add(pagePtr); + + try { + const { PdfiumSave } = + await import("@app/tools/pdfTextEditor/pdfium/PdfiumSave"); + const doc = getEditorDocument(); + if (!doc) return; + const bytes = PdfiumSave.serialize(doc); + if (!bytes || bytes.byteLength === 0) return; + const pdfBase64 = uint8ToBase64(bytes); + + // Batch by font: fire ONE encode-charcodes request per font carrying ALL of + // that font's page chars, instead of one request per (font, char). + const byFont = new Map(); + for (const { ch, perCharFont } of probes) { + const arr = byFont.get(perCharFont); + if (arr) arr.push(ch); + else byFont.set(perCharFont, [ch]); + } + const fontBatches = [...byFont.entries()].map(([font, chars]) => ({ + font, + chars, + })); + + // Cap concurrent encode-charcodes requests to avoid overwhelming the Spring + // backend's PDFBox parser (many parallel POSTs can saturate the thread pool). + const CONCURRENCY = 6; + let batchIdx = 0; + let probesSucceeded = 0; + const workers: Promise[] = []; + for (let w = 0; w < CONCURRENCY; w++) { + workers.push( + (async () => { + while (true) { + const me = batchIdx++; + if (me >= fontBatches.length) return; + const { font, chars } = fontBatches[me]; + const reqKey = `prewarm:${font}:${chars.join("")}`; + if (inFlight.has(reqKey)) continue; + inFlight.add(reqKey); + try { + const json = await postCharcodes({ + pdfBase64, + pageIndex, + // Any of this font's chars is a valid locator (the font renders + // them all). + locatorChar: chars[0], + fontName: readFontName(m, font), + // Program-bytes hash beats the name: PDFium strips subset tags. + fontSha256: getCachedFontProgramSha256(font) ?? undefined, + text: chars.join(""), + }); + if (!json || json.error) continue; + // Map returned charcodes back to chars: the backend appends one + // charcode per NON-missing char in request order. + const missing = new Set(json.missing ?? []); + const codes = json.charcodes ?? []; + let k = 0; + for (const ch of chars) { + if (missing.has(ch)) { + charCache.set(cacheKey(font, ch), null); + continue; + } + const code = codes[k++]; + if (typeof code === "number") { + charCache.set(cacheKey(font, ch), code); + probesSucceeded += 1; + } else { + charCache.set(cacheKey(font, ch), null); + } + } + } finally { + inFlight.delete(reqKey); + } + } + })(), + ); + } + await Promise.all(workers); + if (typeof console !== "undefined") { + console.debug( + `[charcode] backend prewarm pageIdx=${pageIndex} probes=${probes.length} succeeded=${probesSucceeded}`, + ); + } + // If EVERY probe failed (auth, backend down, all 500s) un-mark the page so + // a subsequent focus can retry instead of silently returning early forever. + if (probesSucceeded === 0) { + prewarmedPages.delete(pagePtr); + } + } catch { + /* prewarm is best-effort - errors are silently swallowed */ + prewarmedPages.delete(pagePtr); + } +} + +/** Test-only: clear the per-page prewarm guard. */ +export function _clearPrewarmGuardForTests(): void { + prewarmedPages.clear(); +} + +function getEditorContextForPage(pageIndex: number): { + module: import("@embedpdf/pdfium").WrappedPdfiumModule; + pagePtr: number; + docPtr: number; +} | null { + const doc = getEditorDocument(); + if (!doc) return null; + const pages = doc.loadedPages?.(); + if (!pages) return null; + for (const p of pages) { + if (p.index === pageIndex) { + return { module: doc.module, pagePtr: p.pagePtr, docPtr: doc.docPtr }; + } + } + return null; +} + +function pageIdxOfPagePtr(ctx: ResolverContext): number { + // The ResolverContext only carries pagePtr; map back to index by asking the + // doc model. + const w = window as unknown as { + __editor_store?: { + document?: { + loadedPages?: () => Iterable<{ pagePtr: number; index: number }>; + } | null; + }; + }; + const pages = w.__editor_store?.document?.loadedPages?.(); + if (!pages) return -1; + for (const p of pages) if (p.pagePtr === ctx.pagePtr) return p.index; + return -1; +} + +function getEditorDocument(): + | import("@app/tools/pdfTextEditor/model/EditorDocument").EditorDocument + | null { + // EditorStore.doc is TypeScript-private; the public surface is the + // `document` getter. Always read through that. + const w = window as unknown as { + __editor_store?: { + document?: + | import("@app/tools/pdfTextEditor/model/EditorDocument").EditorDocument + | null; + }; + }; + return w.__editor_store?.document ?? null; +} + +function uint8ToBase64(bytes: Uint8Array): string { + let bin = ""; + const chunk = 0x8000; + // Pass the typed-array subarray straight to apply() (it is array-like) so we + // don't allocate an intermediate Array per chunk for large PDFs. + for (let i = 0; i < bytes.length; i += chunk) { + bin += String.fromCharCode.apply( + null, + bytes.subarray(i, i + chunk) as unknown as number[], + ); + } + return btoa(bin); +} + +function cacheKey(fontPtr: number, ch: string): string { + return `${fontPtr}:${ch}`; +} + +/** Test-only: clear the per-char cache. */ +export function _clearBackendCacheForTests(): void { + charCache.clear(); + negativeUntil.clear(); + inFlight.clear(); +} + +// Reset ALL module-level caches keyed by raw PDFium pointers (per-char +// charcodes, per-page prewarm guard, per-char font handles, in-flight set). +export function resetBackendResolverCaches(): void { + charCache.clear(); + negativeUntil.clear(); + inFlight.clear(); + prewarmedPages.clear(); + fontForCharCache.clear(); + serializedCache = null; + autoPrefetchActive = 0; +} diff --git a/frontend/editor/src/core/tools/pdfTextEditor/charcode/CharcodeStrategy.ts b/frontend/editor/src/core/tools/pdfTextEditor/charcode/CharcodeStrategy.ts new file mode 100644 index 0000000000..a0b4fb69a6 --- /dev/null +++ b/frontend/editor/src/core/tools/pdfTextEditor/charcode/CharcodeStrategy.ts @@ -0,0 +1,82 @@ +// Strategy for resolving Unicode chars to font-specific charcodes when writing +// new text into an existing embedded subset font. +export type CharcodeStrategy = + | "helvetica" // Legacy: always fall back to Helvetica for new chars. + | "cmap" // Parse the embedded font's cmap table. + | "content-stream" // Read raw PDF content streams to extract charcode bytes. + | "backend"; // Send to Spring backend, PDFBox encodes server-side. + +export const CHARCODE_STRATEGIES: readonly CharcodeStrategy[] = [ + "helvetica", + "cmap", + "content-stream", + "backend", +] as const; + +const STORAGE_KEY = "pdfTextEditor.charcodeStrategy"; +const URL_PARAM = "charcodeStrategy"; + +// Resolve the active strategy: URL param wins over localStorage, which wins +// over the default. +export const DEFAULT_CHARCODE_STRATEGY: CharcodeStrategy = "backend"; + +export function getActiveCharcodeStrategy(): CharcodeStrategy { + if (typeof window === "undefined") return DEFAULT_CHARCODE_STRATEGY; + try { + const url = new URL(window.location.href); + const fromUrl = url.searchParams.get(URL_PARAM); + if (fromUrl && isStrategy(fromUrl)) return fromUrl; + } catch { + /* ignore malformed URL */ + } + try { + const fromLs = window.localStorage.getItem(STORAGE_KEY); + if (fromLs && isStrategy(fromLs)) return fromLs; + } catch { + /* localStorage may be disabled */ + } + return DEFAULT_CHARCODE_STRATEGY; +} + +export function setActiveCharcodeStrategy(s: CharcodeStrategy): void { + if (typeof window === "undefined") return; + try { + window.localStorage.setItem(STORAGE_KEY, s); + } catch { + /* best-effort */ + } +} + +function isStrategy(value: string): value is CharcodeStrategy { + return (CHARCODE_STRATEGIES as readonly string[]).includes(value); +} + +// Per-strategy result for a Unicode→charcodes resolve attempt. `charcodes`: the +// array of font-specific bytes/CIDs to pass to FPDFText_SetCharcodes. +export interface CharcodeResolveResult { + charcodes: number[]; + coverage: number; + missing: string[]; + note: string; +} + +// Contract every strategy implementation satisfies. `null` from resolve means +// the strategy can't run AT ALL for this font - caller falls back. +export interface CharcodeResolver { + readonly name: CharcodeStrategy; + // Resolve every char in `text` to a charcode usable with + // FPDFText_SetCharcodes against the given font pointer. + resolve( + font: number, + text: string, + ctx: ResolverContext, + ): CharcodeResolveResult | null; +} + +// Hooks every strategy needs: PDFium module access, the source page handle (for +// content-stream parsing), and fetch() for backend. +export interface ResolverContext { + module: import("@embedpdf/pdfium").WrappedPdfiumModule; + pagePtr: number; + docPtr: number; +} diff --git a/frontend/editor/src/core/tools/pdfTextEditor/charcode/CmapResolver.ts b/frontend/editor/src/core/tools/pdfTextEditor/charcode/CmapResolver.ts new file mode 100644 index 0000000000..cb04944c70 --- /dev/null +++ b/frontend/editor/src/core/tools/pdfTextEditor/charcode/CmapResolver.ts @@ -0,0 +1,339 @@ +import type { + CharcodeResolver, + CharcodeResolveResult, + ResolverContext, +} from "@app/tools/pdfTextEditor/charcode/CharcodeStrategy"; +import { sha256Hex } from "@app/tools/pdfTextEditor/util/sha256"; + +/** Strategy 1: parse the embedded font's cmap table. */ + +interface FontDataModule { + FPDFFont_GetFontData?: ( + font: number, + bufferPtr: number, + length: number, + outSizePtr: number, + ) => boolean; +} + +/** Per-font cmap cache. Keyed by font pointer (stable per document). */ +const cmapCache = new Map | null>(); + +// Per-font SHA-256 (hex) of the embedded font PROGRAM bytes, computed from the +// same FPDFFont_GetFontData read that feeds the cmap parse. +const fontShaCache = new Map(); + +/** Don't hash font programs above this size. */ +const MAX_HASH_BYTES = 8 * 1024 * 1024; + +export class CmapResolver implements CharcodeResolver { + readonly name = "cmap" as const; + + resolve( + font: number, + text: string, + ctx: ResolverContext, + ): CharcodeResolveResult | null { + if (!font) return null; + const cmap = getOrBuildCmap(font, ctx); + if (!cmap) { + return { + charcodes: [], + coverage: 0, + missing: [...text], + note: "cmap unavailable for this font", + }; + } + const charcodes: number[] = []; + const missing: string[] = []; + for (const ch of text) { + const cp = ch.codePointAt(0) ?? 0; + const gid = cmap.get(cp); + if (gid === undefined) { + missing.push(ch); + continue; + } + charcodes.push(gid); + } + return { + charcodes, + coverage: charcodes.length, + missing, + note: `cmap entries: ${cmap.size}, requested: ${text.length}, resolved: ${charcodes.length}`, + }; + } +} + +function getOrBuildCmap( + font: number, + ctx: ResolverContext, +): Map | null { + const cached = cmapCache.get(font); + if (cached !== undefined) return cached; + const built = buildCmap(font, ctx); + cmapCache.set(font, built); + return built; +} + +/** Build + cache a font's cmap. */ +export function primeFontGlyphMap( + font: number, + module: import("@embedpdf/pdfium").WrappedPdfiumModule, +): void { + if (!font) return; + getOrBuildCmap(font, { module, pagePtr: 0, docPtr: 0 }); +} + +/** Read a font's cached Unicode→glyphId cmap WITHOUT touching PDFium. */ +export function getCachedFontGlyphMap( + font: number, +): Map | null { + return cmapCache.get(font) ?? null; +} + +// SHA-256 hex of the font's embedded program bytes, cached by {@link +// primeFontGlyphMap} during the load phase. Safe to call any time. +export function getCachedFontProgramSha256(font: number): string | null { + return fontShaCache.get(font) ?? null; +} + +function buildCmap( + font: number, + ctx: ResolverContext, +): Map | null { + const bytes = readFontData(font, ctx.module); + // Hash alongside the cmap parse - same single PDFium read serves both. + if (!fontShaCache.has(font)) { + let sha: string | null = null; + if (bytes && bytes.length > 0 && bytes.length <= MAX_HASH_BYTES) { + try { + sha = sha256Hex(bytes); + } catch { + sha = null; + } + } + fontShaCache.set(font, sha); + } + if (!bytes) return null; + return parseTrueTypeCmap(bytes); +} + +/** Copy a font's embedded program bytes out of the WASM heap (null = none). */ +function readFontData( + font: number, + m: import("@embedpdf/pdfium").WrappedPdfiumModule, +): Uint8Array | null { + const fontMod = m as unknown as FontDataModule; + if (!fontMod.FPDFFont_GetFontData) return null; + + // First call: ask for the buffer size (pass length=0, read outSize). + const sizePtr = m.pdfium.wasmExports.malloc(4); + try { + const ok = fontMod.FPDFFont_GetFontData(font, 0, 0, sizePtr); + if (!ok) return null; + const size = m.pdfium.getValue(sizePtr, "i32"); + if (size <= 0) return null; + const dataPtr = m.pdfium.wasmExports.malloc(size); + try { + const ok2 = fontMod.FPDFFont_GetFontData(font, dataPtr, size, sizePtr); + if (!ok2) return null; + // Slice() copies out of the WASM heap so we own the bytes. + const heapU8 = (m.pdfium as unknown as { HEAPU8: Uint8Array }).HEAPU8; + return new Uint8Array(heapU8.buffer, dataPtr, size).slice(); + } finally { + m.pdfium.wasmExports.free(dataPtr); + } + } finally { + m.pdfium.wasmExports.free(sizePtr); + } +} + +/** Minimal TrueType / OpenType cmap parser. */ +export function parseTrueTypeCmap( + bytes: Uint8Array, +): Map | null { + const dv = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength); + if (bytes.length < 12) return null; + + // sfnt header: first 4 bytes are the scaler type + // (0x00010000 for TrueType, 'OTTO' for OpenType/CFF, 'true', 'typ1'). + const scaler = dv.getUint32(0); + const isOpenTypeCff = scaler === 0x4f54544f; // 'OTTO' + const isTrueType = + scaler === 0x00010000 || + scaler === 0x74727565 || // 'true' + scaler === 0x74797031; // 'typ1' + if (!isOpenTypeCff && !isTrueType) return null; + + const numTables = dv.getUint16(4); + const tableRecordStart = 12; + // Find the 'cmap' table record. + let cmapOffset = 0; + for (let i = 0; i < numTables; i++) { + const recordOffset = tableRecordStart + i * 16; + if (recordOffset + 16 > bytes.length) return null; + const tag = dv.getUint32(recordOffset); + if (tag === CMAP_TABLE_TAG) { + cmapOffset = dv.getUint32(recordOffset + 8); + break; + } + } + if (cmapOffset === 0 || cmapOffset + 4 > bytes.length) return null; + + const numSubtables = dv.getUint16(cmapOffset + 2); + // Pick the best subtable: prefer Unicode platform (0), then + // Microsoft Unicode (3, encoding 1 or 10). + let bestSubtableOffset = 0; + let bestRank = -1; + for (let i = 0; i < numSubtables; i++) { + const recordOffset = cmapOffset + 4 + i * 8; + if (recordOffset + 8 > bytes.length) continue; + const platformId = dv.getUint16(recordOffset); + const encodingId = dv.getUint16(recordOffset + 2); + const subtableOffset = cmapOffset + dv.getUint32(recordOffset + 4); + const rank = rankSubtable(platformId, encodingId); + if (rank > bestRank) { + bestRank = rank; + bestSubtableOffset = subtableOffset; + } + } + if (bestSubtableOffset === 0) return null; + + // A malformed subtable can read past the buffer (RangeError); never let one + // bad font throw out of the loader's synchronous prime - treat as no cmap. + try { + const format = dv.getUint16(bestSubtableOffset); + if (format === 4) return parseFormat4(dv, bestSubtableOffset); + if (format === 6) return parseFormat6(dv, bestSubtableOffset); + if (format === 12) return parseFormat12(dv, bestSubtableOffset); + } catch { + return null; + } + return null; +} + +function rankSubtable(platformId: number, encodingId: number): number { + // Microsoft Unicode UCS-4 (3, 10) is the highest priority - covers chars + // above U+FFFF. + if (platformId === 3 && encodingId === 10) return 100; + if (platformId === 0 && encodingId === 4) return 90; + if (platformId === 0 && encodingId === 6) return 90; + if (platformId === 3 && encodingId === 1) return 80; + if (platformId === 0) return 70; + return 0; +} + +/** Format 4: segment-mapping-to-delta. The most common cmap subtable. */ +function parseFormat4( + dv: DataView, + offset: number, +): Map | null { + const length = dv.getUint16(offset + 2); + if (offset + length > dv.byteLength) return null; + const segCountX2 = dv.getUint16(offset + 6); + const segCount = segCountX2 / 2; + const endCodesOffset = offset + 14; + const startCodesOffset = endCodesOffset + segCountX2 + 2; + const idDeltasOffset = startCodesOffset + segCountX2; + const idRangeOffsetsOffset = idDeltasOffset + segCountX2; + const glyphIdArrayOffset = idRangeOffsetsOffset + segCountX2; + const out = new Map(); + for (let i = 0; i < segCount; i++) { + const endCode = dv.getUint16(endCodesOffset + i * 2); + const startCode = dv.getUint16(startCodesOffset + i * 2); + const idDelta = dv.getInt16(idDeltasOffset + i * 2); + const idRangeOffset = dv.getUint16(idRangeOffsetsOffset + i * 2); + if (startCode === 0xffff && endCode === 0xffff) continue; + for (let c = startCode; c <= endCode; c++) { + // Cap entries like formats 6/12 - hostile format-4 cmaps can span huge ranges. + if (out.size >= MAX_CMAP_ENTRIES) return out; + let glyphId: number; + if (idRangeOffset === 0) { + glyphId = (c + idDelta) & 0xffff; + } else { + // The spec's idRangeOffset trick: an offset INTO the + // idRangeOffset array itself that points to the glyphIdArray. + const glyphIdOffset = + idRangeOffsetsOffset + i * 2 + idRangeOffset + (c - startCode) * 2; + if ( + glyphIdOffset + 2 > + glyphIdArrayOffset + (length - (glyphIdArrayOffset - offset)) + ) { + continue; + } + const raw = dv.getUint16(glyphIdOffset); + if (raw === 0) continue; + glyphId = (raw + idDelta) & 0xffff; + } + if (glyphId !== 0) out.set(c, glyphId); + } + if (out.size >= MAX_CMAP_ENTRIES) break; + } + return out; +} + +/** Big-endian "cmap" as an sfnt table tag. */ +const CMAP_TABLE_TAG = 0x636d6170; + +// Hard cap on entries built from any one cmap. +const MAX_CMAP_ENTRIES = 200_000; + +/** Format 6: trimmed table mapping. Compact contiguous range. */ +function parseFormat6(dv: DataView, offset: number): Map { + const firstCode = dv.getUint16(offset + 6); + const entryCount = dv.getUint16(offset + 8); + const out = new Map(); + // Bound the loop to the buffer AND the entry cap. + const safeCount = Math.min( + entryCount, + Math.max(0, Math.floor((dv.byteLength - (offset + 10)) / 2)), + MAX_CMAP_ENTRIES, + ); + for (let i = 0; i < safeCount; i++) { + const glyphId = dv.getUint16(offset + 10 + i * 2); + if (glyphId !== 0) out.set(firstCode + i, glyphId); + } + return out; +} + +/** Format 12: segmented coverage for chars above U+FFFF (emoji etc.). */ +function parseFormat12(dv: DataView, offset: number): Map { + const numGroups = dv.getUint32(offset + 12); + const groupsOffset = offset + 16; + const out = new Map(); + // Bound group count to what actually fits in the buffer (12 bytes/group). + const safeGroups = Math.min( + numGroups, + Math.max(0, Math.floor((dv.byteLength - groupsOffset) / 12)), + ); + for (let i = 0; i < safeGroups; i++) { + const recordOffset = groupsOffset + i * 12; + const startCharCode = dv.getUint32(recordOffset); + const endCharCode = dv.getUint32(recordOffset + 4); + const startGlyphId = dv.getUint32(recordOffset + 8); + // Skip inverted ranges; cap a single group's span so one huge/corrupt + // group can't blow the entry budget. + if (endCharCode < startCharCode) continue; + const last = Math.min( + endCharCode, + startCharCode + (MAX_CMAP_ENTRIES - out.size) - 1, + ); + for (let c = startCharCode; c <= last; c++) { + const gid = startGlyphId + (c - startCharCode); + if (gid !== 0) out.set(c, gid); + } + if (out.size >= MAX_CMAP_ENTRIES) break; + } + return out; +} + +/** Clear the per-font cmap + program-hash caches. */ +export function resetCmapCache(): void { + cmapCache.clear(); + fontShaCache.clear(); +} + +/** Test-only alias for {@link resetCmapCache}. */ +export function _clearCmapCacheForTests(): void { + resetCmapCache(); +} diff --git a/frontend/editor/src/core/tools/pdfTextEditor/charcode/ContentStreamResolver.ts b/frontend/editor/src/core/tools/pdfTextEditor/charcode/ContentStreamResolver.ts new file mode 100644 index 0000000000..c6bcf38fb9 --- /dev/null +++ b/frontend/editor/src/core/tools/pdfTextEditor/charcode/ContentStreamResolver.ts @@ -0,0 +1,139 @@ +import type { + CharcodeResolver, + CharcodeResolveResult, + ResolverContext, +} from "@app/tools/pdfTextEditor/charcode/CharcodeStrategy"; + +// Strategy 2: scrape Unicode→charcode mappings by walking the page's existing +// text via PDFium's text page API. + +interface TextPageModule { + FPDFText_LoadPage?: (page: number) => number; + FPDFText_ClosePage?: (textPage: number) => void; + FPDFText_CountChars?: (textPage: number) => number; + FPDFText_GetUnicode?: (textPage: number, idx: number) => number; + FPDFText_GetTextObject?: (textPage: number, idx: number) => number; +} + +interface FontReadModule { + FPDFTextObj_GetFont?: (obj: number) => number; +} + +/** Cache: per-page-pointer Map>. */ +const perPageCache = new Map>>(); + +export class ContentStreamResolver implements CharcodeResolver { + readonly name = "content-stream" as const; + + resolve( + font: number, + text: string, + ctx: ResolverContext, + ): CharcodeResolveResult | null { + if (!font) return null; + const unicodeToCharcode = getOrBuildMap(font, ctx); + if (!unicodeToCharcode) { + return { + charcodes: [], + coverage: 0, + missing: [...text], + note: "content-stream scan returned no entries for this font", + }; + } + const charcodes: number[] = []; + const missing: string[] = []; + for (const ch of text) { + const cp = ch.codePointAt(0) ?? 0; + const cc = unicodeToCharcode.get(cp); + if (cc === undefined) { + missing.push(ch); + continue; + } + charcodes.push(cc); + } + return { + charcodes, + coverage: charcodes.length, + missing, + note: `content-stream entries: ${unicodeToCharcode.size}, requested: ${text.length}, resolved: ${charcodes.length}`, + }; + } +} + +function getOrBuildMap( + font: number, + ctx: ResolverContext, +): Map | null { + let pageMap = perPageCache.get(ctx.pagePtr); + if (!pageMap) { + pageMap = buildPageMap(ctx); + perPageCache.set(ctx.pagePtr, pageMap); + } + return pageMap.get(font) ?? null; +} + +function buildPageMap(ctx: ResolverContext): Map> { + const m = ctx.module; + const tpMod = m as unknown as TextPageModule; + const fontMod = m as unknown as FontReadModule; + const out = new Map>(); + if ( + !tpMod.FPDFText_LoadPage || + !tpMod.FPDFText_CountChars || + !tpMod.FPDFText_GetUnicode || + !tpMod.FPDFText_GetTextObject || + !fontMod.FPDFTextObj_GetFont + ) { + return out; + } + const textPage = tpMod.FPDFText_LoadPage(ctx.pagePtr); + if (!textPage) return out; + try { + const count = tpMod.FPDFText_CountChars(textPage); + // Per-FONT counter (not per-text-object): every unique Unicode we encounter + // in a given font gets the next sequential CID starting at 1. + const perFontNext = new Map(); + for (let i = 0; i < count; i++) { + const unicode = tpMod.FPDFText_GetUnicode(textPage, i); + if (!unicode) continue; + const obj = tpMod.FPDFText_GetTextObject(textPage, i); + if (!obj) continue; + let font = 0; + try { + font = fontMod.FPDFTextObj_GetFont(obj); + } catch { + /* skip */ + } + if (!font) continue; + let fontMap = out.get(font); + if (!fontMap) { + fontMap = new Map(); + out.set(font, fontMap); + } + if (!fontMap.has(unicode)) { + const nextCid = (perFontNext.get(font) ?? 0) + 1; + perFontNext.set(font, nextCid); + fontMap.set(unicode, nextCid); + } + } + } finally { + if (tpMod.FPDFText_ClosePage) { + try { + tpMod.FPDFText_ClosePage(textPage); + } catch { + /* best-effort */ + } + } + } + return out; +} + +/** Clear the per-page Unicode→charcode cache. */ +export function resetContentStreamCache(): void { + perPageCache.clear(); +} + +/** Test-only alias for {@link resetContentStreamCache}. */ +export function _clearContentStreamCacheForTests(): void { + resetContentStreamCache(); +} diff --git a/frontend/editor/src/core/tools/pdfTextEditor/charcode/charcodeRegistry.ts b/frontend/editor/src/core/tools/pdfTextEditor/charcode/charcodeRegistry.ts new file mode 100644 index 0000000000..ab93eccbf5 --- /dev/null +++ b/frontend/editor/src/core/tools/pdfTextEditor/charcode/charcodeRegistry.ts @@ -0,0 +1,185 @@ +import { + BackendResolver, + findFontForChar, + fontIsReusable, + prewarmBackendCacheForPage, + styleClassFromName, +} from "@app/tools/pdfTextEditor/charcode/BackendResolver"; + +/** Re-export so the emit path can do per-char font lookup. */ +export { + findFontForChar, + fontIsReusable, + prewarmBackendCacheForPage, + styleClassFromName, +}; +import { + CharcodeResolver, + CharcodeStrategy, + getActiveCharcodeStrategy, + ResolverContext, +} from "@app/tools/pdfTextEditor/charcode/CharcodeStrategy"; +import { CmapResolver } from "@app/tools/pdfTextEditor/charcode/CmapResolver"; +import { ContentStreamResolver } from "@app/tools/pdfTextEditor/charcode/ContentStreamResolver"; + +/** Per-emit telemetry. */ +export interface CharcodeEvent { + timestamp: number; + strategy: CharcodeStrategy; + text: string; + fontPtr: number; + resolved: number[]; + missing: string[]; + note: string; + outcome: + | "charcodes-ok" + | "charcodes-call-failed" + | "partial-coverage-fallback" + | "no-strategy" + | "no-font"; +} + +const eventListeners = new Set<(e: CharcodeEvent) => void>(); +const recentEvents: CharcodeEvent[] = []; +const MAX_RECENT = 50; + +export function subscribeCharcodeEvents( + cb: (e: CharcodeEvent) => void, +): () => void { + eventListeners.add(cb); + return () => eventListeners.delete(cb); +} + +export function getRecentCharcodeEvents(): CharcodeEvent[] { + return [...recentEvents]; +} + +function emitEvent(e: CharcodeEvent): void { + recentEvents.push(e); + if (recentEvents.length > MAX_RECENT) recentEvents.shift(); + // Expose recent events on window for emit-path-aware Playwright tests. + if (typeof window !== "undefined") { + ( + window as unknown as { + __charcode_events?: CharcodeEvent[]; + } + ).__charcode_events = [...recentEvents]; + } + for (const cb of eventListeners) { + try { + cb(e); + } catch { + /* swallow listener errors */ + } + } +} + +/** Test-only: clear the in-memory recent-events buffer + window hook. */ +export function _clearRecentCharcodeEventsForTests(): void { + recentEvents.length = 0; + if (typeof window !== "undefined") { + ( + window as unknown as { __charcode_events?: CharcodeEvent[] } + ).__charcode_events = []; + } +} + +/** Public entry point for the emit path to record an attempt. */ +export function emitCharcodeEvent( + e: Omit & { + timestamp?: number; + }, +): void { + emitEvent({ + ...e, + // performance.now is available in browser + Node 16+. + timestamp: + typeof performance !== "undefined" && performance.now + ? performance.now() + : recentEvents.length, + }); +} + +const resolvers: Record = { + helvetica: null, // legacy: do nothing, caller falls back. + cmap: new CmapResolver(), + "content-stream": new ContentStreamResolver(), + backend: new BackendResolver(), +}; + +// Get the resolver for the currently active strategy. Returns null for +// `helvetica` (the legacy "always fall back" mode). +export function activeResolver(): CharcodeResolver | null { + const s = getActiveCharcodeStrategy(); + return resolvers[s]; +} + +interface SetCharcodesModule { + FPDFText_SetCharcodes?: ( + textObj: number, + charcodesPtr: number, + count: number, + ) => boolean; +} + +/** Write `charcodes` into `textObj` via FPDFText_SetCharcodes. */ +export function setCharcodesOn( + m: import("@embedpdf/pdfium").WrappedPdfiumModule, + textObj: number, + charcodes: number[], +): boolean { + const ccMod = m as unknown as SetCharcodesModule; + if (!ccMod.FPDFText_SetCharcodes || charcodes.length === 0) return false; + // Allocate a uint32 buffer in the WASM heap. + const bufSize = charcodes.length * 4; + const buf = m.pdfium.wasmExports.malloc(bufSize); + try { + const heapU8 = (m.pdfium as unknown as { HEAPU8: Uint8Array }).HEAPU8; + const view = new Uint32Array(heapU8.buffer, buf, charcodes.length); + for (let i = 0; i < charcodes.length; i++) view[i] = charcodes[i] >>> 0; + return !!ccMod.FPDFText_SetCharcodes(textObj, buf, charcodes.length); + } catch { + return false; + } finally { + m.pdfium.wasmExports.free(buf); + } +} + +/** Strategy-aware resolve helper used by the emit path. */ +export function tryResolveCharcodes( + font: number, + text: string, + ctx: ResolverContext, + allowContentStreamFallback = false, +): { + strategy: CharcodeStrategy; + result: ReturnType; +} | null { + const r = activeResolver(); + if (r) { + const result = r.resolve(font, text, ctx); + if (result && result.coverage === [...text].length) { + return { strategy: r.name, result }; + } + // Active resolver (e.g. backend with a cold cache) did not fully cover the + // text. + if (allowContentStreamFallback && r.name !== "content-stream") { + const cs = resolvers["content-stream"]; + const csResult = cs?.resolve(font, text, ctx); + if (csResult && csResult.coverage === [...text].length) { + return { strategy: "content-stream", result: csResult }; + } + } + return { strategy: r.name, result }; + } + // No active resolver (helvetica strategy). Still try the client-side + // content-stream reuse when explicitly allowed. + if (allowContentStreamFallback) { + const cs = resolvers["content-stream"]; + const csResult = cs?.resolve(font, text, ctx); + if (csResult && csResult.coverage === [...text].length) { + return { strategy: "content-stream", result: csResult }; + } + } + return null; +} diff --git a/frontend/editor/src/core/tools/pdfTextEditor/commands/AlignParagraphLinesCommand.ts b/frontend/editor/src/core/tools/pdfTextEditor/commands/AlignParagraphLinesCommand.ts new file mode 100644 index 0000000000..b352efbb52 --- /dev/null +++ b/frontend/editor/src/core/tools/pdfTextEditor/commands/AlignParagraphLinesCommand.ts @@ -0,0 +1,153 @@ +import type { Command } from "@app/tools/pdfTextEditor/commands/Command"; +import type { EditorDocument } from "@app/tools/pdfTextEditor/model/EditorDocument"; +import type { TextRun } from "@app/tools/pdfTextEditor/model/TextRun"; +import { transformObject } from "@app/tools/pdfTextEditor/util/objectTransform"; + +export type LineAlignMode = "left" | "center-h" | "right"; + +// Horizontally align the LINES inside a single multi-line paragraph run +// relative to each other. +export class AlignParagraphLinesCommand implements Command { + readonly type = "align-paragraph-lines"; + private readonly pageIndex: number; + private readonly runId: string; + private readonly mode: LineAlignMode; + /** Per-line dx actually applied, parallel to the run's line slots. */ + private appliedDx: number[] = []; + + constructor(opts: { pageIndex: number; runId: string; mode: LineAlignMode }) { + this.pageIndex = opts.pageIndex; + this.runId = opts.runId; + this.mode = opts.mode; + } + + /** True when this run can be line-aligned (a multi-line paragraph). */ + static canAlign(run: TextRun): boolean { + return run.paragraphLineSlots.length >= 2; + } + + private lineExtent( + run: TextRun, + i: number, + ): { left: number; right: number } | null { + const slot = run.paragraphLineSlots[i]; + if (!slot || slot.mergedFromBounds.length === 0) return null; + let left = Infinity; + let right = -Infinity; + for (const b of slot.mergedFromBounds) { + if (b.x < left) left = b.x; + if (b.right > right) right = b.right; + } + return Number.isFinite(left) && Number.isFinite(right) + ? { left, right } + : null; + } + + apply(doc: EditorDocument): void { + const page = doc.page(this.pageIndex); + const run = page.findRun(this.runId); + if (!run || !AlignParagraphLinesCommand.canAlign(run)) return; + + // Paragraph-wide left/right edge across every line. + const extents = run.paragraphLineSlots.map((_, i) => + this.lineExtent(run, i), + ); + let paraLeft = Infinity; + let paraRight = -Infinity; + for (const e of extents) { + if (!e) continue; + if (e.left < paraLeft) paraLeft = e.left; + if (e.right > paraRight) paraRight = e.right; + } + if (!Number.isFinite(paraLeft) || !Number.isFinite(paraRight)) return; + const paraCentre = (paraLeft + paraRight) / 2; + + const m = doc.module; + this.appliedDx = run.paragraphLineSlots.map((_, i) => { + const e = extents[i]; + if (!e) return 0; + const dx = + this.mode === "left" + ? paraLeft - e.left + : this.mode === "right" + ? paraRight - e.right + : paraCentre - (e.left + e.right) / 2; + return Math.abs(dx) < 0.01 ? 0 : dx; + }); + + let moved = false; + run.paragraphLineSlots.forEach((_slot, i) => { + const dx = this.appliedDx[i]; + if (!dx) return; + this.shiftLine(m, run, i, dx); + moved = true; + }); + if (!moved) { + this.appliedDx = []; + return; + } + this.refreshBounds(run); + run.dirty = true; + page.markDirty(); + page.markNeedsGenerate(); + } + + revert(doc: EditorDocument): void { + if (this.appliedDx.length === 0) return; + const page = doc.page(this.pageIndex); + const run = page.findRun(this.runId); + if (!run) return; + const m = doc.module; + run.paragraphLineSlots.forEach((_slot, i) => { + const dx = this.appliedDx[i]; + if (!dx) return; + this.shiftLine(m, run, i, -dx); + }); + this.refreshBounds(run); + run.dirty = true; + page.markDirty(); + page.markNeedsGenerate(); + this.appliedDx = []; + } + + /** Translate one line's glyph objects + its model bounds by dx. */ + private shiftLine( + m: import("@embedpdf/pdfium").WrappedPdfiumModule, + run: TextRun, + i: number, + dx: number, + ): void { + const slot = run.paragraphLineSlots[i]; + if (!slot) return; + const seen = new Set(); + for (const ptr of slot.mergedFromPtrs) { + if (!ptr || seen.has(ptr)) continue; + seen.add(ptr); + try { + transformObject(m, ptr, 1, 0, 0, 1, dx, 0); + } catch { + /* best-effort */ + } + } + slot.matrixE += dx; + slot.mergedFromBounds = slot.mergedFromBounds.map((b) => ({ + x: b.x + dx, + right: b.right + dx, + })); + } + + /** Recompute the paragraph rep's horizontal bounds from its lines. */ + private refreshBounds(run: TextRun): void { + let left = Infinity; + let right = -Infinity; + for (let i = 0; i < run.paragraphLineSlots.length; i++) { + const e = this.lineExtent(run, i); + if (!e) continue; + if (e.left < left) left = e.left; + if (e.right > right) right = e.right; + } + if (Number.isFinite(left) && Number.isFinite(right)) { + run.bounds = { ...run.bounds, x: left, width: Math.max(0, right - left) }; + } + } +} diff --git a/frontend/editor/src/core/tools/pdfTextEditor/commands/ChangeZOrderCommand.ts b/frontend/editor/src/core/tools/pdfTextEditor/commands/ChangeZOrderCommand.ts new file mode 100644 index 0000000000..52ea1f39b3 --- /dev/null +++ b/frontend/editor/src/core/tools/pdfTextEditor/commands/ChangeZOrderCommand.ts @@ -0,0 +1,140 @@ +import type { Command } from "@app/tools/pdfTextEditor/commands/Command"; +import type { EditorDocument } from "@app/tools/pdfTextEditor/model/EditorDocument"; +import { collectMemberPtrs } from "@app/tools/pdfTextEditor/commands/editTextHelpers"; + +export type ZOrderMode = + | "to-front" // top of stack (rendered last, on top of everything) + | "to-back" // bottom of stack (rendered first, underneath everything) + | "forward" // swap with the object directly above it + | "backward"; // swap with the object directly below it + +interface InsertAtModule { + FPDFPage_InsertObjectAtIndex?: ( + page: number, + obj: number, + idx: number, + ) => boolean; +} + +/** One warning per session, not one per apply() - a drag can fire dozens. */ +let warnedMissingInsertAt = false; + +/** Re-order a text run or image within its page's content-stream stack. */ +export class ChangeZOrderCommand implements Command { + readonly type = "change-z-order"; + private readonly pageIndex: number; + private readonly runId: string | null; + private readonly imageId: string | null; + private readonly mode: ZOrderMode; + /** Member ptrs at their pre-apply indices, ascending. */ + private memberPrev: Array<{ ptr: number; idx: number }>; + + constructor(opts: { + pageIndex: number; + runId?: string; + imageId?: string; + mode: ZOrderMode; + }) { + this.pageIndex = opts.pageIndex; + this.runId = opts.runId ?? null; + this.imageId = opts.imageId ?? null; + this.mode = opts.mode; + this.memberPrev = []; + } + + apply(doc: EditorDocument): void { + const page = doc.page(this.pageIndex); + const m = doc.module; + const ext = m as unknown as InsertAtModule; + if (!ext.FPDFPage_InsertObjectAtIndex) { + if (typeof console !== "undefined" && !warnedMissingInsertAt) { + warnedMissingInsertAt = true; + console.warn( + "[z-order] FPDFPage_InsertObjectAtIndex unavailable - ChangeZOrderCommand is a no-op for this PDFium build", + ); + } + return; + } + const ptrs = this.resolveMemberPtrs(page); + if (ptrs.size === 0) return; + const total = m.FPDFPage_CountObjects(page.pagePtr); + // Locate every member at page level, ascending by index. Members + // nested inside form XObjects don't appear here (known limitation). + const located: Array<{ ptr: number; idx: number }> = []; + for (let i = 0; i < total; i++) { + const o = m.FPDFPage_GetObject(page.pagePtr, i); + if (ptrs.has(o)) located.push({ ptr: o, idx: i }); + } + if (located.length === 0 || located.length === total) return; + const k = located.length; + const bottomIdx = located[0].idx; + const topIdx = located[k - 1].idx; + // The group is only "already in place" when it is contiguous AND at the + // target edge. + const contiguous = topIdx - bottomIdx === k - 1; + let insertAt: number; + switch (this.mode) { + case "to-front": + if (contiguous && topIdx === total - 1) return; // already at front + insertAt = total - k; + break; + case "to-back": + if (contiguous && bottomIdx === 0) return; // already at back + insertAt = 0; + break; + case "forward": + // Land just above the object that sat directly above the group's top. + if (topIdx >= total - 1) return; + insertAt = topIdx + 2 - k; + break; + case "backward": + // Land just below the object that sat directly below the group's bottom. + if (bottomIdx <= 0) return; + insertAt = bottomIdx - 1; + break; + } + this.memberPrev = located; + for (const { ptr } of located) { + m.FPDFPage_RemoveObject(page.pagePtr, ptr); + } + located.forEach(({ ptr }, j) => { + ext.FPDFPage_InsertObjectAtIndex!(page.pagePtr, ptr, insertAt + j); + }); + // markDirty bumps the revision so PageView re-renders the bitmap. + page.markDirty(); + page.markNeedsGenerate(); + } + + revert(doc: EditorDocument): void { + if (this.memberPrev.length === 0) return; + const page = doc.page(this.pageIndex); + const m = doc.module; + const ext = m as unknown as InsertAtModule; + if (!ext.FPDFPage_InsertObjectAtIndex) return; + for (const { ptr } of this.memberPrev) { + m.FPDFPage_RemoveObject(page.pagePtr, ptr); + } + // Re-inserting in ascending original index order reconstructs the + // exact pre-apply list. + for (const { ptr, idx } of this.memberPrev) { + ext.FPDFPage_InsertObjectAtIndex(page.pagePtr, ptr, idx); + } + page.markDirty(); + page.markNeedsGenerate(); + } + + private resolveMemberPtrs( + page: import("@app/tools/pdfTextEditor/model/Page").Page, + ): Set { + if (this.runId) { + const run = page.runs.find((r) => r.id === this.runId); + if (!run) return new Set(); + return new Set(collectMemberPtrs(run).filter((p) => p !== 0)); + } + if (this.imageId) { + const img = page.images.find((i) => i.id === this.imageId); + return img?.pdfiumObjPtr ? new Set([img.pdfiumObjPtr]) : new Set(); + } + return new Set(); + } +} diff --git a/frontend/editor/src/core/tools/pdfTextEditor/commands/Command.ts b/frontend/editor/src/core/tools/pdfTextEditor/commands/Command.ts new file mode 100644 index 0000000000..ee05506068 --- /dev/null +++ b/frontend/editor/src/core/tools/pdfTextEditor/commands/Command.ts @@ -0,0 +1,18 @@ +import type { EditorDocument } from "@app/tools/pdfTextEditor/model/EditorDocument"; + +// Every user-initiated mutation goes through a Command so it can be recorded, +// replayed, and reverted by the HistoryStack. +export interface Command { + /** Stable identifier for telemetry / debugging. */ + readonly type: string; + apply(doc: EditorDocument): void; + revert(doc: EditorDocument): void; + // Optional - some commands describe themselves for the UI (e.g. "Type in run + // 'A1'", shown in undo history tooltips). + describe?(): string; + /** Optional coalescing key. Return null / undefined to never coalesce. */ + coalesceKey?(): string | null; + // Optional - when true, a matching `coalesceKey` merges this command into the + // previous undo step however long ago that step ran. + coalesceIgnoresTimeWindow?(previous: Command | null): boolean; +} diff --git a/frontend/editor/src/core/tools/pdfTextEditor/commands/CompositeCommand.ts b/frontend/editor/src/core/tools/pdfTextEditor/commands/CompositeCommand.ts new file mode 100644 index 0000000000..8433c689f8 --- /dev/null +++ b/frontend/editor/src/core/tools/pdfTextEditor/commands/CompositeCommand.ts @@ -0,0 +1,40 @@ +import type { Command } from "@app/tools/pdfTextEditor/commands/Command"; +import type { EditorDocument } from "@app/tools/pdfTextEditor/model/EditorDocument"; + +/** Groups several already-applied commands into one undo/redo step. */ +export class CompositeCommand implements Command { + readonly type = "composite"; + private readonly commands: Command[]; + + constructor(commands: Command[]) { + this.commands = commands; + } + + /** Append another already-applied command to this group. */ + push(cmd: Command): void { + this.commands.push(cmd); + } + + /** The most recent child - used to derive the group's coalesce key. */ + get last(): Command { + return this.commands[this.commands.length - 1]; + } + + apply(doc: EditorDocument): void { + for (const cmd of this.commands) cmd.apply(doc); + } + + revert(doc: EditorDocument): void { + for (let i = this.commands.length - 1; i >= 0; i--) { + this.commands[i].revert(doc); + } + } + + coalesceKey(): string | null { + return this.last.coalesceKey?.() ?? null; + } + + describe(): string { + return this.last.describe?.() ?? "Edit"; + } +} diff --git a/frontend/editor/src/core/tools/pdfTextEditor/commands/DeleteImageCommand.ts b/frontend/editor/src/core/tools/pdfTextEditor/commands/DeleteImageCommand.ts new file mode 100644 index 0000000000..c860d53d90 --- /dev/null +++ b/frontend/editor/src/core/tools/pdfTextEditor/commands/DeleteImageCommand.ts @@ -0,0 +1,112 @@ +import type { Command } from "@app/tools/pdfTextEditor/commands/Command"; +import type { EditorDocument } from "@app/tools/pdfTextEditor/model/EditorDocument"; +import { ImageObject } from "@app/tools/pdfTextEditor/model/ImageObject"; +import type { ImageObjectSnapshot } from "@app/tools/pdfTextEditor/types"; + +/** Remove an image object from a page. */ +export class DeleteImageCommand implements Command { + readonly type = "delete-image"; + private readonly pageIndex: number; + private readonly imageId: string; + private snapshot: ImageObjectSnapshot | null; + private cachedObjPtr: number; + /** Index in the page's object list at the moment of deletion. */ + private originalIndex: number; + + constructor(opts: { pageIndex: number; imageId: string }) { + this.pageIndex = opts.pageIndex; + this.imageId = opts.imageId; + this.snapshot = null; + this.cachedObjPtr = 0; + this.originalIndex = -1; + } + + apply(doc: EditorDocument): void { + const page = doc.page(this.pageIndex); + const img = page.findImage(this.imageId); + if (!img) return; + if (this.snapshot === null) { + this.snapshot = img.snapshot(); + this.cachedObjPtr = img.pdfiumObjPtr; + // Record the original index so revert can re-insert in place. + const total = doc.module.FPDFPage_CountObjects(page.pagePtr); + let foundIdx = -1; + for (let i = 0; i < total; i++) { + if ( + doc.module.FPDFPage_GetObject(page.pagePtr, i) === img.pdfiumObjPtr + ) { + foundIdx = i; + break; + } + } + this.originalIndex = foundIdx; + } + if (img.pdfiumObjPtr) { + doc.module.FPDFPage_RemoveObject(page.pagePtr, img.pdfiumObjPtr); + } + page.setImages(page.images.filter((i) => i.id !== img.id)); + page.markDirty(); + page.markNeedsGenerate(); + } + + revert(doc: EditorDocument): void { + if (!this.snapshot || !this.cachedObjPtr) return; + const page = doc.page(this.pageIndex); + const m = doc.module as unknown as { + FPDFPage_InsertObjectAtIndex?: ( + page: number, + obj: number, + index: number, + ) => boolean; + FPDFPage_InsertObject: (page: number, obj: number) => void; + }; + const insertAt = m.FPDFPage_InsertObjectAtIndex; + let inserted = false; + if (typeof insertAt === "function" && this.originalIndex >= 0) { + try { + inserted = insertAt.call( + m, + page.pagePtr, + this.cachedObjPtr, + this.originalIndex, + ); + } catch { + inserted = false; + } + } + if (!inserted) { + // Fallback: re-insert at end. + doc.module.FPDFPage_InsertObject(page.pagePtr, this.cachedObjPtr); + if (this.originalIndex >= 0) { + const total = doc.module.FPDFPage_CountObjects(page.pagePtr); + const lastIdx = total - 1; + // Step the newly-inserted object down by removing+reinserting the + // objects that should be ABOVE it. + for (let i = this.originalIndex; i < lastIdx; i++) { + const ptr = doc.module.FPDFPage_GetObject( + page.pagePtr, + this.originalIndex, + ); + if (!ptr || ptr === this.cachedObjPtr) break; + doc.module.FPDFPage_RemoveObject(page.pagePtr, ptr); + doc.module.FPDFPage_InsertObject(page.pagePtr, ptr); + } + } + } + const restored = new ImageObject({ + ...this.snapshot, + pdfiumObjPtr: this.cachedObjPtr, + }); + // Insert back into the images array at the original position when + // we know it, so any UI ordering matches the visual stacking. + const images = [...page.images]; + if (this.originalIndex >= 0 && this.originalIndex <= images.length) { + images.splice(this.originalIndex, 0, restored); + } else { + images.push(restored); + } + page.setImages(images); + page.markDirty(); + page.markNeedsGenerate(); + } +} diff --git a/frontend/editor/src/core/tools/pdfTextEditor/commands/DeleteObjectCommand.ts b/frontend/editor/src/core/tools/pdfTextEditor/commands/DeleteObjectCommand.ts new file mode 100644 index 0000000000..bf4526bf9b --- /dev/null +++ b/frontend/editor/src/core/tools/pdfTextEditor/commands/DeleteObjectCommand.ts @@ -0,0 +1,94 @@ +import type { Command } from "@app/tools/pdfTextEditor/commands/Command"; +import type { EditorDocument } from "@app/tools/pdfTextEditor/model/EditorDocument"; +import type { TextRunSnapshot } from "@app/tools/pdfTextEditor/types"; +import type { TextRun } from "@app/tools/pdfTextEditor/model/TextRun"; +import { + collectContainersByPtr, + collectMemberPtrs, + removeMemberPtrs, +} from "@app/tools/pdfTextEditor/commands/editTextHelpers"; + +/** Remove a run from the page model and from PDFium. */ +interface CapturedPtr { + ptr: number; + containerPtr: number; +} + +export class DeleteObjectCommand implements Command { + readonly type = "delete-object"; + private readonly pageIndex: number; + private readonly runId: string; + private snapshot: TextRunSnapshot | null; + /** Every sub-object pointer + its container at apply time. */ + private cachedPtrs: CapturedPtr[]; + /** The live run instance, re-attached on revert to keep all fields intact. */ + private removedRun: TextRun | null = null; + + constructor(opts: { pageIndex: number; runId: string }) { + this.pageIndex = opts.pageIndex; + this.runId = opts.runId; + this.snapshot = null; + this.cachedPtrs = []; + } + + apply(doc: EditorDocument): void { + const page = doc.page(this.pageIndex); + const run = page.findRun(this.runId); + if (!run) return; + if (this.snapshot === null) { + this.snapshot = run.snapshot(); + this.removedRun = run; + const memberPtrs = collectMemberPtrs(run); + const containerByPtr = collectContainersByPtr(run); + const seen = new Set(); + this.cachedPtrs = []; + for (const ptr of memberPtrs) { + if (!ptr || seen.has(ptr)) continue; + seen.add(ptr); + this.cachedPtrs.push({ + ptr, + containerPtr: containerByPtr.get(ptr) ?? run.containerPtr, + }); + } + } + removeMemberPtrs( + doc.module, + page, + this.cachedPtrs.map((c) => c.ptr), + new Map(this.cachedPtrs.map((c) => [c.ptr, c.containerPtr])), + run.containerPtr, + ); + page.setRuns(page.runs.filter((r) => r.id !== run.id)); + page.markDirty(); + page.markNeedsGenerate(); + } + + revert(doc: EditorDocument): void { + if (!this.removedRun || this.cachedPtrs.length === 0) return; + const page = doc.page(this.pageIndex); + const m = doc.module; + const formMod = m as unknown as { + FPDFFormObj_InsertObject?: (form: number, obj: number) => boolean; + }; + // Re-insert every captured sub-object. + for (const { ptr, containerPtr } of this.cachedPtrs) { + if (!ptr) continue; + try { + if (containerPtr && formMod.FPDFFormObj_InsertObject) { + formMod.FPDFFormObj_InsertObject(containerPtr, ptr); + } else { + m.FPDFPage_InsertObject(page.pagePtr, ptr); + } + } catch { + /* best-effort */ + } + } + // Re-attach the live instance so every field (mergedFrom*, paragraph*, + // coverRectPtr, containerPtr) is restored exactly as before delete. + if (!page.findRun(this.removedRun.id)) { + page.setRuns([...page.runs, this.removedRun]); + } + page.markDirty(); + page.markNeedsGenerate(); + } +} diff --git a/frontend/editor/src/core/tools/pdfTextEditor/commands/DuplicateRunCommand.ts b/frontend/editor/src/core/tools/pdfTextEditor/commands/DuplicateRunCommand.ts new file mode 100644 index 0000000000..c0486745f2 --- /dev/null +++ b/frontend/editor/src/core/tools/pdfTextEditor/commands/DuplicateRunCommand.ts @@ -0,0 +1,100 @@ +import type { Command } from "@app/tools/pdfTextEditor/commands/Command"; +import type { EditorDocument } from "@app/tools/pdfTextEditor/model/EditorDocument"; +import { TextRun } from "@app/tools/pdfTextEditor/model/TextRun"; +import { writeUtf16 } from "@app/services/pdfiumService"; +import { + fallbackFamilyFor, + fallbackFontIdFor, +} from "@app/tools/pdfTextEditor/util/fontCapability"; +import { sanitizeForBase14 } from "@app/tools/pdfTextEditor/commands/editTextHelpers"; + +// Clone a text run at a fixed offset (default 12pt right + 12pt down) so the +// user can quickly stamp the same text elsewhere on the page. +const OFFSET = 12; + +export class DuplicateRunCommand implements Command { + readonly type = "duplicate-run"; + private readonly pageIndex: number; + private readonly runId: string; + private createdRunId: string | null; + private createdObjPtr: number; + + constructor(opts: { pageIndex: number; runId: string }) { + this.pageIndex = opts.pageIndex; + this.runId = opts.runId; + this.createdRunId = null; + this.createdObjPtr = 0; + } + + get insertedRunId(): string | null { + return this.createdRunId; + } + + apply(doc: EditorDocument): void { + const page = doc.page(this.pageIndex); + const src = page.findRun(this.runId); + if (!src) return; + const m = doc.module; + const fallback = fallbackFamilyFor(src.fontId); + const newPtr = m.FPDFPageObj_NewTextObj( + doc.docPtr, + fallback, + Math.max(4, src.fontSize), + ); + if (!newPtr) return; + // Base-14 (WinAnsi) can't render >U+00FF; sanitize so non-Latin code + // points are dropped rather than persisted as U+00FF ydieresis tofu. + const textPtr = writeUtf16( + m, + sanitizeForBase14(src.text.replace(/\r?\n/g, " ")), + ); + try { + m.FPDFText_SetText(newPtr, textPtr); + } finally { + m.pdfium.wasmExports.free(textPtr); + } + m.FPDFPageObj_SetFillColor( + newPtr, + src.fill.r, + src.fill.g, + src.fill.b, + src.fill.a, + ); + const newX = src.matrix.e + OFFSET; + const newY = src.matrix.f - OFFSET; + m.FPDFPageObj_Transform(newPtr, 1, 0, 0, 1, newX, newY); + m.FPDFPage_InsertObject(page.pagePtr, newPtr); + const id = `p${page.index}-dup-${page.runs.length}-${newPtr}`; + const clone = new TextRun({ + id, + pageIndex: page.index, + pdfiumObjPtr: newPtr, + bounds: { + x: newX, + y: newY, + width: src.bounds.width, + height: src.bounds.height, + }, + matrix: { a: 1, b: 0, c: 0, d: 1, e: newX, f: newY }, + text: src.text, + fontId: fallbackFontIdFor(fallback), + fontSize: src.fontSize, + fill: { ...src.fill }, + fontSubset: false, + }); + page.setRuns([...page.runs, clone]); + page.markDirty(); + page.markNeedsGenerate(); + this.createdRunId = id; + this.createdObjPtr = newPtr; + } + + revert(doc: EditorDocument): void { + if (!this.createdObjPtr || !this.createdRunId) return; + const page = doc.page(this.pageIndex); + doc.module.FPDFPage_RemoveObject(page.pagePtr, this.createdObjPtr); + page.setRuns(page.runs.filter((r) => r.id !== this.createdRunId)); + page.markDirty(); + page.markNeedsGenerate(); + } +} diff --git a/frontend/editor/src/core/tools/pdfTextEditor/commands/EditTextCommand.ts b/frontend/editor/src/core/tools/pdfTextEditor/commands/EditTextCommand.ts new file mode 100644 index 0000000000..1033176dad --- /dev/null +++ b/frontend/editor/src/core/tools/pdfTextEditor/commands/EditTextCommand.ts @@ -0,0 +1,1698 @@ +import type { Command } from "@app/tools/pdfTextEditor/commands/Command"; +import type { EditorDocument } from "@app/tools/pdfTextEditor/model/EditorDocument"; +import { PdfiumTextWriter } from "@app/tools/pdfTextEditor/pdfium/PdfiumTextWriter"; +import { sampleBackground } from "@app/tools/pdfTextEditor/pdfium/BackgroundSampler"; +import { + charcodesResolveFully, + collectContainersByPtr, + collectMemberPtrs, + emitFillRect, + emitTextLine, + everyCharIn, + inkFromRun, + measureObjSpanPt, + removeMemberPtrs, + rotationFromMatrix, + warmOnPageAdvances, + planLineOrigins, + emitRunLines, +} from "@app/tools/pdfTextEditor/commands/editTextHelpers"; +import { + bestFontPtrForText, + applyParagraphEditPlan, + applyPartialEditPlan, + planModifiesWhitespace, + planParagraphEdit, + planPartialEdit, + setObjText, + type ParagraphEditPlan, + type PartialEditPlan, +} from "@app/tools/pdfTextEditor/commands/partialEdit"; +import { + fallbackFamilyFor, + fallbackFontIdFor, +} from "@app/tools/pdfTextEditor/util/fontCapability"; +import type { Page } from "@app/tools/pdfTextEditor/model/Page"; +import type { + ParagraphLineSlot, + TextRun, +} from "@app/tools/pdfTextEditor/model/TextRun"; +import { transformObject } from "@app/tools/pdfTextEditor/util/objectTransform"; + +interface RevertLine { + text: string; + x: number; + y: number; + fill: { r: number; g: number; b: number; a: number }; + fontSize: number; + /** Source run's letter-spacing so an undo re-emit keeps the tracking. */ + charSpacingPt: number; +} + +/** One rebuilt line for {@link EditTextCommand.rebuildAsOverlayModel}. */ +interface RebuildLine { + baselineY: number; + fontSize: number; + subRuns: Array<{ ptr: number; text: string; x: number; removed: boolean }>; +} + +/** Snapshot of a run's paragraph model for the line-edit revert. */ +interface RunModelSnapshot { + text: string; + matrixE: number; + matrixF: number; + bounds: { x: number; y: number; width: number; height: number }; + paragraphLineHeight: number; + paragraphMemberPtrs: number[]; + paragraphMemberContainers: number[]; + paragraphMemberFs: number[]; + paragraphLeafPtrs: number[]; + paragraphLeafContainers: number[]; + paragraphLineSlots: ParagraphLineSlot[]; + mergedFromPtrs: number[]; + mergedFromTexts: string[]; + mergedFromBounds: Array<{ x: number; right: number }>; + mergedFromCharStarts: number[]; + fontId: string; + fontSubset: boolean; + pdfiumObjPtr: number; +} + +// True when a partial-edit plan only ADDED objects (no original object was +// freed via removePtrs, none mutated in place via a "modify" op). +function planIsPureInsert(plan: PartialEditPlan): boolean { + return ( + plan.removePtrs.length === 0 && plan.ops.every((op) => op.type !== "modify") + ); +} + +/** Edit a text run. */ +export class EditTextCommand implements Command { + readonly type = "edit-text"; + private readonly pageIndex: number; + private readonly runId: string; + private readonly nextText: string; + private prevText: string | null = null; + + private overlaid = false; + private prevObjPtr = 0; + private prevFontId: string | null = null; + /** + * The original object's PDFium font handle, captured before the overlay + * replaces it. Font handles are document-level and outlive the object, so + * the revert can re-emit in the run's OWN face instead of a base-14 + * lookalike. + */ + private prevFontPtr = 0; + private coverRectPtr = 0; + private createdPtrs: number[] = []; + private newTextPtr = 0; + private revertLines: RevertLine[] = []; + /** Rotation of the run when apply() snapshotted it; re-applied on revert. */ + private revertRotation: { cos: number; sin: number } | null = null; + /** Set when the apply path took the partial-edit (LCS) shortcut. */ + private partialPlan: PartialEditPlan | null = null; + private partialInsertedPtrs: number[] = []; + private prevMergedFromPtrs: number[] = []; + private prevMergedFromTexts: string[] = []; + private prevMergedFromBounds: Array<{ x: number; right: number }> = []; + /** Set when the apply path took the paragraph-aware partial shortcut. */ + private paragraphPlan: ParagraphEditPlan | null = null; + private paragraphInsertedPtrs: number[] = []; + private prevParagraphSlots: ParagraphLineSlot[] = []; + // Full pre-edit model snapshot, captured by the partial / paragraph-partial + // apply paths. + private editSnapshot: RunModelSnapshot | null = null; + /** Set when the apply path took the paragraph line add/remove shortcut. */ + private lineEdit: { + /** Matched lines translated to a new baseline (reversed on revert). */ + moves: Array<{ ptr: number; dy: number }>; + /** Fresh objects emitted for new/changed lines (removed on revert). */ + createdPtrs: number[]; + /** Deleted lines, re-emitted as fallback on revert. */ + removed: Array<{ text: string; x: number; y: number; fontSize: number }>; + prev: RunModelSnapshot; + } | null = null; + + constructor(opts: { pageIndex: number; runId: string; nextText: string }) { + this.pageIndex = opts.pageIndex; + this.runId = opts.runId; + this.nextText = opts.nextText; + } + + apply(doc: EditorDocument): void { + const page = doc.page(this.pageIndex); + const run = page.findRun(this.runId); + if (!run) return; + if (this.prevText === null) this.prevText = run.text; + // No-op edit: a contentEditable insert can fire several `input` events for + // one keystroke burst, re-dispatching the SAME final text. + if (this.prevText === this.nextText) return; + + const alreadyBase14 = /^base14:/.test(run.fontId); + // A run rotated within the page can't use the surgical partial/paragraph + // paths - those assume horizontal layout. + const isRotated = !!rotationFromMatrix(run.matrix); + + // PARAGRAPH-AWARE PARTIAL PATH: paragraphs (multi-line runs) keep per-line + // sub-run data in `paragraphLineSlots`. + if ( + this.partialPlan === null && + this.paragraphPlan === null && + run.paragraphLineSlots.length > 1 && + !isRotated + ) { + const paraPlan = planParagraphEdit( + run, + this.prevText ?? "", + this.nextText, + ); + if (paraPlan) { + this.paragraphPlan = paraPlan; + this.prevParagraphSlots = paraPlan.prevSlots; + this.editSnapshot = snapshotRunModel(run); + const result = applyParagraphEditPlan(doc, page, run, paraPlan); + this.paragraphInsertedPtrs = result.insertedPtrs; + run.paragraphLineSlots = result.newSlots; + run.bounds = { + ...run.bounds, + x: result.newBoundsX, + width: clampWidthToPage( + result.newBoundsX, + result.newBoundsWidth, + page, + ), + }; + // Keep mergedFrom* synchronized with slot[0] so a later + // single-line partial edit on the rep continues to work. + const firstSlot = result.newSlots[0]; + run.mergedFromPtrs = [...firstSlot.mergedFromPtrs]; + run.mergedFromTexts = [...firstSlot.mergedFromTexts]; + run.mergedFromBounds = firstSlot.mergedFromBounds.map((b) => ({ + ...b, + })); + run.mergedFromCharStarts = [...firstSlot.mergedFromCharStarts]; + if (firstSlot.mergedFromPtrs.length > 0) { + run.pdfiumObjPtr = firstSlot.mergedFromPtrs[0]; + } + run.text = this.nextText; + run.dirty = true; + page.markDirty(); + page.markNeedsGenerate(); + return; + } + } + + // PARAGRAPH LINE ADD/REMOVE PATH. + if ( + this.partialPlan === null && + this.paragraphPlan === null && + this.lineEdit === null && + this.prevText !== null && + this.prevText.length > 0 && + run.paragraphLineSlots.length >= 1 && + !isRotated + ) { + const prevLines = this.prevText.split(/\r?\n/); + const nextLines = this.nextText.split(/\r?\n/); + if (prevLines.length !== nextLines.length) { + if (run.paragraphLineSlots.length === prevLines.length) { + // Slots map 1:1 to lines (a grow-mode paragraph) - diff per line. + this.applyParagraphLineEdit(doc, page, run, prevLines, nextLines); + run.text = this.nextText; + run.dirty = true; + page.markDirty(); + page.markNeedsGenerate(); + return; + } + if ( + this.nextText.startsWith(this.prevText) && + /^\r?\n/.test(this.nextText.slice(this.prevText.length)) + ) { + // Soft-wrapped paragraph: can't diff per line. + this.applyParagraphAppend(doc, page, run); + run.text = this.nextText; + run.dirty = true; + page.markDirty(); + page.markNeedsGenerate(); + return; + } + } + } + + // SURGICAL DIFF PATH (single-line). + if ( + this.partialPlan === null && + run.mergedFromPtrs.length > 0 && + run.paragraphLineSlots.length < 2 && + !/\r?\n/.test(this.nextText) && + !isRotated + ) { + const partial = planPartialEdit(run, this.prevText ?? "", this.nextText); + // An in-place "modify" op that re-SetTexts whitespace paints „ on an + // embedded subset font with no space glyph. + if (partial && !planModifiesWhitespace(partial)) { + this.partialPlan = partial; + this.prevMergedFromPtrs = [...run.mergedFromPtrs]; + this.prevMergedFromTexts = [...run.mergedFromTexts]; + this.prevMergedFromBounds = run.mergedFromBounds.map((b) => ({ ...b })); + this.editSnapshot = snapshotRunModel(run); + const result = applyPartialEditPlan(doc, page, run, partial); + this.partialInsertedPtrs = result.insertedPtrs; + run.mergedFromPtrs = result.newMergedFromPtrs; + run.mergedFromTexts = result.newMergedFromTexts; + run.mergedFromBounds = result.newMergedFromBounds; + run.mergedFromCharStarts = result.newMergedFromCharStarts; + run.bounds = { + ...run.bounds, + x: result.newBoundsX, + width: clampWidthToPage( + result.newBoundsX, + result.newBoundsWidth, + page, + ), + }; + if (result.newMergedFromPtrs.length > 0) { + run.pdfiumObjPtr = result.newMergedFromPtrs[0]; + } + run.text = this.nextText; + run.dirty = true; + page.markDirty(); + return; + } + } + + // Force overlay whenever the in-place SetText path can't keep every PDFium + // object up to date: - paragraphs or newline-containing text. + const needsMultiObjectEmit = + run.paragraphMemberPtrs.length > 1 || + run.paragraphLeafPtrs.length > 1 || + /\r?\n/.test(this.nextText) || + /\s\s/.test(this.nextText); + const needsOverlay = + needsMultiObjectEmit || + (!this.overlaid && + !alreadyBase14 && + (run.mergedFromPtrs.length > 0 || + run.fontSubset || + run.pdfiumObjPtr !== 0)); + + if (!needsOverlay) { + const restoreText = run.text; + const restoreBounds = run.bounds; + run.text = this.nextText; + run.dirty = true; + page.markDirty(); + if (PdfiumTextWriter.commitRunText(doc, page, run)) return; + // The object's font could not encode the new text - `run.fontId` said + // base-14 but `pdfiumObjPtr` still pointed at the original (Type 3 / + // symbolic subset) object, so SetText wrote filler charcodes. Undo and + // take the overlay path, which resolves charcodes and validates the emit. + run.text = restoreText; + run.bounds = restoreBounds; + } + + this.overlaid = true; + this.prevObjPtr = run.pdfiumObjPtr; + if (this.prevFontId === null) this.prevFontId = run.fontId; + if (this.prevFontPtr === 0 && run.containerPtr === 0 && run.pdfiumObjPtr) { + this.prevFontPtr = safeGetFont(doc.module, run.pdfiumObjPtr); + } + const fallbackFamily = fallbackFamilyFor(this.prevFontId); + const m = doc.module; + + const bg = sampleBackground(m, page, run.bounds); + // \r/\n are split into separate output lines, so they must NOT gate font + // reuse. + const safeChars = everyCharIn( + this.nextText.replace(/[\r\n]/g, ""), + this.prevText ?? "", + ); + // Reusing the source font handle works when every nextText char already + // appears in prevText, which guarantees a glyph. That proxy is strict: it + // threw away a fully embedded face the moment a NEW letter was typed. So + // also accept the case where the charcodes provably resolve for the whole + // string, which is exactly what the emit path needs to succeed. + const candidateFontPtr = run.pdfiumObjPtr + ? safeGetFont(m, run.pdfiumObjPtr) + : 0; + const canReuseFont = + run.containerPtr === 0 && + (safeChars || + charcodesResolveFully( + m, + candidateFontPtr, + this.nextText.replace(/[\r\n]/g, ""), + page.pagePtr, + doc.docPtr, + )); + // Borrow the font of the member sharing the most chars with the new text. + const borrowPtrs = collectMemberPtrs(run); + const borrowTexts = + run.mergedFromTexts.length === borrowPtrs.length + ? run.mergedFromTexts + : borrowPtrs.map(() => run.text); + const originalFontPtr = canReuseFont + ? bestFontPtrForText(m, borrowPtrs, borrowTexts, this.nextText) || + (run.pdfiumObjPtr ? safeGetFont(m, run.pdfiumObjPtr) : 0) + : 0; + + this.revertLines = snapshotRevertLines(run, this.prevText ?? ""); + this.revertRotation = rotationFromMatrix(run.matrix) ?? null; + + // Detach any cover rect that a PRIOR overlay edit left on the page. + if (run.coverRectPtr) { + try { + m.FPDFPage_RemoveObject(page.pagePtr, run.coverRectPtr); + } catch { + /* best-effort */ + } + run.coverRectPtr = 0; + } + + // Measure the page's glyph advances BEFORE the source objects go away: + // for a Type 3 face this is the only place a real advance can come from. + warmOnPageAdvances(m, page.pagePtr); + + const memberPtrs = collectMemberPtrs(run); + const containers = collectContainersByPtr(run); + const allRemoved = removeMemberPtrs( + m, + page, + memberPtrs, + containers, + run.containerPtr, + ); + + // Only stamp a cover rect when the sampler is CONFIDENT it found a uniform + // background colour. + if (!allRemoved && bg.confident) { + this.coverRectPtr = emitFillRect(m, page, run.bounds, bg.fill); + if (this.coverRectPtr) { + this.createdPtrs.push(this.coverRectPtr); + run.coverRectPtr = this.coverRectPtr; + } + } + + const outputLines = this.nextText.split(/\r?\n/); + const lineHeight = + run.paragraphLineHeight > 0 + ? run.paragraphLineHeight + : run.fontSize * 1.2; + // One "line anchor" ptr per output line; plus any extra per-word ptrs from + // space preservation, kept for leaf removal on subsequent edits. + const lineAnchorPtrs: number[] = []; + const lineAnchorYs: number[] = []; + const allEmittedPtrs: number[] = []; + // Per-line emit metadata used to rebuild paragraphLineSlots so the NEXT + // edit can route back through paragraph-aware partial-edit instead of. + const perLineEmits: Array<{ + ptrs: number[]; + texts: string[]; + text: string; + x: number; + y: number; + }> = []; + const emitted = emitRunLines({ + doc, + page, + run, + lines: outputLines, + origins: planLineOrigins(run, outputLines.length, lineHeight), + originalFontPtr, + originalFontSubset: run.fontSubset, + fallbackFamily, + }); + for (const line of emitted) { + // Empty lines keep a placeholder slot; a FAILED emit is dropped entirely. + if (line.text.length === 0) { + perLineEmits.push({ + ptrs: [], + texts: [], + text: "", + x: line.x, + y: line.y, + }); + continue; + } + if (line.ptrs.length === 0) { + // A line whose emit produced nothing still owns its character range. + // Skipping it shifts every later slot onto the wrong line of run.text. + perLineEmits.push({ + ptrs: [], + texts: [], + text: line.text, + x: line.x, + y: line.y, + }); + continue; + } + this.createdPtrs.push(...line.ptrs); + allEmittedPtrs.push(...line.ptrs); + lineAnchorPtrs.push(line.ptrs[0]); + lineAnchorYs.push(line.y); + perLineEmits.push({ + ptrs: line.ptrs, + texts: line.texts, + text: line.text, + x: line.x, + y: line.y, + }); + } + + if (lineAnchorPtrs.length > 0) { + this.newTextPtr = lineAnchorPtrs[0]; + run.pdfiumObjPtr = lineAnchorPtrs[0]; + if (originalFontPtr === 0) { + run.fontId = fallbackFontIdFor(fallbackFamily); + run.fontSubset = false; + } else { + // Borrow path: the new objects use the borrowed font handle. + run.fontSubset = false; + } + run.paragraphMemberPtrs = lineAnchorPtrs; + run.paragraphMemberContainers = lineAnchorPtrs.map(() => 0); + run.paragraphMemberFs = [...lineAnchorYs]; + // Every per-word emit becomes a leaf - so the next edit's removal + // pass cleans them up alongside the anchors. + run.paragraphLeafPtrs = allEmittedPtrs; + run.paragraphLeafContainers = allEmittedPtrs.map(() => 0); + if (perLineEmits.length > 1) { + // Remember the line height so paragraph-partial / future overlay + // emits land at the same baselines we just established. + run.paragraphLineHeight = lineHeight; + } + } + + run.mergedFromPtrs = []; + // Clear the parallel arrays too: planPartialEdit bails on length mismatch. + run.mergedFromTexts = []; + run.mergedFromBounds = []; + run.mergedFromCharStarts = []; + // Rebuild paragraphLineSlots from the fresh emit so the next edit on this + // paragraph can re-engage the font-preserving partial path. + if (perLineEmits.length > 1) { + run.paragraphLineSlots = buildSlotsFromOverlayEmit( + m, + run, + perLineEmits, + originalFontPtr === 0 ? fallbackFontIdFor(fallbackFamily) : run.fontId, + ); + } else { + // Single-line emit. + run.paragraphLineSlots = []; + } + // Don't reset paragraphLeafPtrs here - we just set them above to the + // freshly-emitted chunks so the next overlay edit can remove them. + // The emit replaced every object this run owns, so the old bounds can + // describe geometry that is gone - a box narrower than its own glyphs + // leaves the overlay unusable over correctly drawn text. Only ever GROW it + // here: trailing whitespace legitimately extends a box past its ink, and + // shrinking to the ink would erase that. + const span = measureObjSpanPt(m, allEmittedPtrs); + if (span) { + const left = Math.min(run.bounds.x, span.left); + const right = Math.max(run.bounds.x + run.bounds.width, span.right); + run.bounds = { ...run.bounds, x: left, width: Math.max(0, right - left) }; + } + run.text = this.nextText; + run.dirty = true; + page.markDirty(); + page.markNeedsGenerate(); + } + + // Exactly one revert strategy member may be set per apply. Enforced only by + // guard ordering, so fail fast in dev if two paths ran or a member leaked. + private assertSingleRevertPath(): void { + const set = + (this.lineEdit !== null ? 1 : 0) + + (this.paragraphPlan !== null ? 1 : 0) + + (this.partialPlan !== null ? 1 : 0) + + (this.overlaid ? 1 : 0); + if (set > 1) { + console.error( + `EditTextCommand revert: ${set} strategy members set, expected <=1`, + ); + } + } + + revert(doc: EditorDocument): void { + const page = doc.page(this.pageIndex); + const run = page.findRun(this.runId); + if (!run || this.prevText === null) return; + this.assertSingleRevertPath(); + const m = doc.module; + + // Paragraph line add/remove revert: move matched lines back to their + // original baselines, drop the freshly-emitted new/changed lines. + if (this.lineEdit) { + for (let i = this.lineEdit.moves.length - 1; i >= 0; i--) { + const mv = this.lineEdit.moves[i]; + try { + transformObject(m, mv.ptr, 1, 0, 0, 1, 0, -mv.dy); + } catch { + /* best-effort */ + } + } + for (const ptr of this.lineEdit.createdPtrs) { + if (!ptr) continue; + try { + m.FPDFPage_RemoveObject(page.pagePtr, ptr); + } catch { + /* best-effort */ + } + } + restoreRunModel(run, this.lineEdit.prev); + if (this.lineEdit.removed.length > 0) { + const fallbackFamily = fallbackFamilyFor(this.prevFontId ?? run.fontId); + for (const rem of this.lineEdit.removed) { + const ptrs = emitTextLine({ + doc, + page, + text: rem.text, + x: rem.x, + y: rem.y, + fontSize: rem.fontSize, + fill: run.fill, + ...inkFromRun(run), + originalFontPtr: 0, + charSpacingPt: run.charSpacingPt, + fallbackFamily, + }); + patchSlotPtrsByBaseline(m, run, rem.y, ptrs, rem.text); + } + reflattenLeafArrays(run); + } + run.text = this.prevText; + run.dirty = true; + this.lineEdit = null; + page.markDirty(); + page.markNeedsGenerate(); + return; + } + + // Paragraph-aware partial revert: remove every per-slot insert ptr, re-emit + // fallback chunks at each removed sub-run's original spot. + if (this.paragraphPlan) { + // Remove the chunks the forward apply inserted. + for (const ptr of this.paragraphInsertedPtrs) { + if (!ptr) continue; + try { + m.FPDFPage_RemoveObject(page.pagePtr, ptr); + } catch { + /* best-effort */ + } + } + this.paragraphInsertedPtrs = []; + // Pure-insert edit (no original object freed/mutated): every original + // object is still alive, so restore the exact pre-edit model. + const pureInsert = this.paragraphPlan.perSlot.every( + (e) => e.plan !== null && planIsPureInsert(e.plan), + ); + if (pureInsert && this.editSnapshot) { + restoreRunModel(run, this.editSnapshot); + run.text = this.prevText; + run.dirty = true; + this.paragraphPlan = null; + page.markDirty(); + page.markNeedsGenerate(); + return; + } + const revertFallback = fallbackFamilyFor(this.prevFontId ?? run.fontId); + // Rebuild every line from the pre-edit slots: kept/modified sub-runs keep + // their live original object. + const lines: RebuildLine[] = []; + for (let s = 0; s < this.prevParagraphSlots.length; s++) { + const prevSlot = this.prevParagraphSlots[s]; + const entry = this.paragraphPlan.perSlot.find((e) => e.slotIdx === s); + if (entry && entry.plan) { + for (const op of entry.plan.ops) { + if (op.type === "modify" && op.subRunIdx !== undefined) { + setObjText( + m, + prevSlot.mergedFromPtrs[op.subRunIdx], + prevSlot.mergedFromTexts[op.subRunIdx] ?? "", + ); + } + } + } + // A fresh-emit slot (plan === null) had ALL its original objects + // removed during apply, so re-emit every one of them on revert. + const removed = new Set( + entry + ? entry.plan + ? entry.plan.removePtrs.map((r) => r.ptr) + : prevSlot.mergedFromPtrs + : [], + ); + lines.push({ + baselineY: prevSlot.baselineY, + fontSize: prevSlot.fontSize, + subRuns: prevSlot.mergedFromPtrs.map((ptr, i) => ({ + ptr, + text: prevSlot.mergedFromTexts[i] ?? "", + x: prevSlot.mergedFromBounds[i]?.x ?? prevSlot.matrixE, + removed: removed.has(ptr), + })), + }); + } + this.rebuildAsOverlayModel(doc, page, run, lines, revertFallback); + run.text = this.prevText; + run.dirty = true; + this.paragraphPlan = null; + page.markDirty(); + page.markNeedsGenerate(); + return; + } + + // Partial-edit fast path revert: the removed sub-objects are gone from + // PDFium permanently. + if (this.partialPlan) { + for (const ptr of this.partialInsertedPtrs) { + if (!ptr) continue; + try { + m.FPDFPage_RemoveObject(page.pagePtr, ptr); + } catch { + /* best-effort */ + } + } + this.partialInsertedPtrs = []; + // In-place "modify" sub-runs kept their object (and font); restore + // their original text so undo shows the pre-edit characters. + for (const op of this.partialPlan.ops) { + if (op.type === "modify" && op.subRunIdx !== undefined) { + setObjText( + m, + this.prevMergedFromPtrs[op.subRunIdx], + this.prevMergedFromTexts[op.subRunIdx] ?? "", + ); + } + } + // No original objects were destroyed: restore the EXACT pre-edit model so + // undo keeps the original embedded fonts AND redo re-engages the. + if (this.partialPlan.removePtrs.length === 0 && this.editSnapshot) { + restoreRunModel(run, this.editSnapshot); + run.text = this.prevText; + run.dirty = true; + this.partialPlan = null; + page.markDirty(); + page.markNeedsGenerate(); + return; + } + const revertFallback = fallbackFamilyFor(this.prevFontId ?? run.fontId); + const removed = new Set(this.partialPlan.removePtrs.map((r) => r.ptr)); + this.rebuildAsOverlayModel( + doc, + page, + run, + [ + { + baselineY: run.matrix.f, + fontSize: run.fontSize, + subRuns: this.prevMergedFromPtrs.map((ptr, i) => ({ + ptr, + text: this.prevMergedFromTexts[i] ?? "", + x: this.prevMergedFromBounds[i]?.x ?? run.matrix.e, + removed: removed.has(ptr), + })), + }, + ], + revertFallback, + ); + run.text = this.prevText; + run.dirty = true; + this.partialPlan = null; + page.markDirty(); + page.markNeedsGenerate(); + return; + } + + if (!this.overlaid) { + run.text = this.prevText; + run.dirty = true; + page.markDirty(); + PdfiumTextWriter.commitRunText(doc, page, run); + return; + } + + for (const ptr of this.createdPtrs) { + if (!ptr) continue; + try { + m.FPDFPage_RemoveObject(page.pagePtr, ptr); + } catch { + /* best-effort */ + } + } + this.coverRectPtr = 0; + this.newTextPtr = 0; + this.createdPtrs = []; + + // Everything else the run still owns goes too, because the re-emit below + // rebuilds the run whole. + // + // A typed burst coalesces into ONE undo step covering several commands. + // The first revert removes its own createdPtrs and re-emits; the second + // then finds ITS createdPtrs already gone, removes nothing, and re-emits + // again - leaving the first revert's objects orphaned on the page. Two + // characters typed mid-word undid to "Heading in a Qbigger bigger + // sizesize": doubled, overlapping glyphs that read as a changed font. + for (const ptr of run.paragraphLeafPtrs) { + if (!ptr) continue; + try { + m.FPDFPage_RemoveObject(page.pagePtr, ptr); + } catch { + /* best-effort - the ptr may already be gone */ + } + } + + // PDFium has no insert-into-form-xobject API, so the truly-original + // pointers (if they lived in a form) are gone forever. + const revertFallback = fallbackFamilyFor(this.prevFontId ?? ""); + const lineAnchorPtrs: number[] = []; + const allRestoredPtrs: number[] = []; + for (const line of this.revertLines) { + const ptrs = emitTextLine({ + doc, + page, + text: line.text, + x: line.x, + y: line.y, + fontSize: line.fontSize, + fill: line.fill, + // The run's own font, not a base-14 stand-in: re-emitting an embedded + // face as Helvetica is what made undo look like it changed the font. + originalFontPtr: this.prevFontPtr, + charSpacingPt: line.charSpacingPt, + fallbackFamily: revertFallback, + // Keep the run's original orientation - without this, undoing an + // edit on a rotated run scattered its text axis-aligned. + rotation: this.revertRotation ?? undefined, + // ...and its ink. applyInkState writes the mode unconditionally, so + // omitting this forced every restored object back to fill: undo on + // invisible OCR text stamped visible glyphs over the scan. + ...inkFromRun(run), + }); + if (ptrs.length === 0) continue; + lineAnchorPtrs.push(ptrs[0]); + allRestoredPtrs.push(...ptrs); + } + + run.pdfiumObjPtr = lineAnchorPtrs[0] ?? this.prevObjPtr; + // Only claim the fallback when we actually emitted in it. + if (this.prevFontPtr === 0) { + run.fontId = fallbackFontIdFor(revertFallback); + run.fontSubset = false; + } else if (this.prevFontId !== null) { + run.fontId = this.prevFontId; + } + run.text = this.prevText; + run.mergedFromPtrs = []; + run.paragraphMemberPtrs = lineAnchorPtrs; + run.paragraphMemberContainers = lineAnchorPtrs.map(() => 0); + run.paragraphMemberFs = this.revertLines.map((l) => l.y); + run.paragraphLeafPtrs = allRestoredPtrs; + run.paragraphLeafContainers = allRestoredPtrs.map(() => 0); + run.containerPtr = 0; + run.dirty = true; + this.overlaid = false; + page.markDirty(); + page.markNeedsGenerate(); + } + + // Apply a paragraph edit that changed the LINE COUNT (Enter typed or a + // newline deleted) where slots map 1:1 to lines. + private applyParagraphLineEdit( + doc: EditorDocument, + page: Page, + run: TextRun, + prevLines: string[], + nextLines: string[], + ): void { + const m = doc.module; + const slots = run.paragraphLineSlots; + const lineHeight = + run.paragraphLineHeight > 0 + ? run.paragraphLineHeight + : run.fontSize * 1.2; + const topBaseline = slots[0]?.baselineY ?? run.matrix.f; + const leftX = slots[0]?.matrixE ?? run.matrix.e; + const fallbackFamily = fallbackFamilyFor(this.prevFontId ?? run.fontId); + // Re-emitted lines keep the run's embedded face. Joining two lines with + // Delete/Backspace only REMOVES characters, so every glyph the joined line + // needs already rendered in this font; emitting at base-14 turned the whole + // line a different typeface. Read before the loop starts mutating. + const memberPtrs = collectMemberPtrs(run); + const memberTexts = + run.mergedFromTexts.length === memberPtrs.length + ? run.mergedFromTexts + : memberPtrs.map(() => run.text); + const reuseFontPtr = + run.containerPtr === 0 + ? bestFontPtrForText(m, memberPtrs, memberTexts, run.text) || + (run.pdfiumObjPtr ? safeGetFont(m, run.pdfiumObjPtr) : 0) + : 0; + const match = lineLCS(prevLines, nextLines); + + this.lineEdit = { + moves: [], + createdPtrs: [], + removed: [], + prev: snapshotRunModel(run), + }; + + const newSlots: ParagraphLineSlot[] = []; + const newLeaf: number[] = []; + const newLeafContainers: number[] = []; + const newMemberPtrs: number[] = []; + const newMemberFs: number[] = []; + const usedPrev = new Set(); + let cursor = 0; + const baselines = keptLeadingBaselines( + nextLines.length, + match, + slots, + topBaseline, + lineHeight, + ); + for (let i = 0; i < nextLines.length; i++) { + const text = nextLines[i]; + const y = baselines[i]; + const prevIdx = match.get(i); + let slot: ParagraphLineSlot; + if (prevIdx !== undefined && slots[prevIdx]) { + // Unchanged line: keep its objects, translate to the new baseline. + usedPrev.add(prevIdx); + const src = slots[prevIdx]; + const dy = y - src.baselineY; + if (Math.abs(dy) > 0.001) { + for (const ptr of src.mergedFromPtrs) { + if (!ptr) continue; + try { + transformObject(m, ptr, 1, 0, 0, 1, 0, dy); + } catch { + /* best-effort - stale ptr */ + } + this.lineEdit.moves.push({ ptr, dy }); + } + } + slot = cloneSlot(src); + slot.baselineY = y; + for (const ptr of src.mergedFromPtrs) { + if (ptr) { + newLeaf.push(ptr); + newLeafContainers.push(src.containerPtr); + } + } + newMemberPtrs.push(src.mergedFromPtrs[0] ?? 0); + newMemberFs.push(y); + } else if (text.length === 0) { + // Seeded from the line this one was split off, so the blank line keeps + // the paragraph's font instead of being stamped base-14 before the + // user has typed a character into it. + slot = emptySlot( + y, + leftX, + run, + fallbackFamily, + newSlots[newSlots.length - 1] ?? slots[0], + ); + newMemberPtrs.push(0); + newMemberFs.push(y); + } else { + // New / changed line: re-emit it reusing the run's embedded face. + // Keep the line's OWN left edge - a table row grouped as a paragraph + // has a different x per line, and slot 0's x drops it into the + // neighbouring column. + const lineX = slots[i]?.matrixE ?? leftX; + const emittedTexts: string[] = []; + const ptrs = emitTextLine({ + outTexts: emittedTexts, + doc, + page, + text, + x: lineX, + y, + fontSize: run.fontSize, + fill: run.fill, + ...inkFromRun(run), + originalFontPtr: reuseFontPtr, + originalFontSubset: run.fontSubset, + charSpacingPt: run.charSpacingPt, + fallbackFamily, + }); + this.lineEdit.createdPtrs.push(...ptrs); + for (const p of ptrs) { + newLeaf.push(p); + newLeafContainers.push(0); + } + newMemberPtrs.push(ptrs[0] ?? 0); + newMemberFs.push(y); + slot = buildSlotForLine( + m, + ptrs, + text, + y, + lineX, + run, + reuseFontPtr ? run.fontId : fallbackFontIdFor(fallbackFamily), + emittedTexts, + ); + } + slot.startChar = cursor; + slot.endChar = cursor + text.length; + cursor += text.length + 1; + newSlots.push(slot); + } + + // Remove objects of any prev line no next line reused. + for (let j = 0; j < slots.length; j++) { + if (usedPrev.has(j)) continue; + const src = slots[j]; + if (prevLines[j]) { + this.lineEdit.removed.push({ + text: prevLines[j], + x: src.mergedFromBounds[0]?.x ?? src.matrixE, + y: src.baselineY, + fontSize: src.fontSize, + }); + } + for (const ptr of src.mergedFromPtrs) { + if (!ptr) continue; + try { + m.FPDFPage_RemoveObject(page.pagePtr, ptr); + } catch { + /* best-effort */ + } + } + } + + // Write the model, PRESERVING matched lines' original objects. + run.paragraphLineSlots = newSlots; + run.paragraphLeafPtrs = newLeaf; + run.paragraphLeafContainers = newLeafContainers; + run.paragraphMemberPtrs = newMemberPtrs; + run.paragraphMemberContainers = newMemberPtrs.map(() => 0); + run.paragraphMemberFs = newMemberFs; + run.paragraphLineHeight = lineHeight; + run.matrix = { ...run.matrix, e: leftX, f: topBaseline }; + if (newLeaf[0]) run.pdfiumObjPtr = newLeaf[0]; + const s0 = newSlots[0]; + if (s0) { + run.mergedFromPtrs = [...s0.mergedFromPtrs]; + run.mergedFromTexts = [...s0.mergedFromTexts]; + run.mergedFromBounds = s0.mergedFromBounds.map((b) => ({ ...b })); + run.mergedFromCharStarts = [...s0.mergedFromCharStarts]; + } + let maxRight = leftX; + for (const s of newSlots) { + for (const b of s.mergedFromBounds) { + if (b.right > maxRight) maxRight = b.right; + } + } + run.bounds = { + x: leftX, + y: topBaseline - (newSlots.length - 1) * lineHeight - run.fontSize * 0.25, + width: Math.max(0, maxRight - leftX), + height: newSlots.length * lineHeight + run.fontSize * 0.25, + }; + } + + /** Apply a paragraph edit that APPENDED lines (Enter + text at the end). */ + private applyParagraphAppend( + doc: EditorDocument, + page: Page, + run: TextRun, + ): void { + const m = doc.module; + const slots = run.paragraphLineSlots; + const lineHeight = + run.paragraphLineHeight > 0 + ? run.paragraphLineHeight + : run.fontSize * 1.2; + const leftX = slots[0]?.matrixE ?? run.matrix.e; + const bottomBaseline = Math.min( + run.matrix.f, + ...slots.map((s) => s.baselineY), + ); + const fallbackFamily = fallbackFamilyFor(this.prevFontId ?? run.fontId); + + this.lineEdit = { + moves: [], + createdPtrs: [], + removed: [], + prev: snapshotRunModel(run), + }; + + // The caller only routes here when the suffix is a pure newline-prefixed + // append, so split keeps a leading "" entry for that first break, skipped. + const appendedLines = this.nextText + .slice(this.prevText!.length) + .split(/\r?\n/); + const newSlots: ParagraphLineSlot[] = []; + const newLeaf: number[] = []; + const newMemberPtrs: number[] = []; + const newMemberFs: number[] = []; + let cursor = this.prevText!.length; + let below = 0; + for (let li = 1; li < appendedLines.length; li++) { + const text = appendedLines[li]; + cursor += 1; // the "\n" separator before this line + below += 1; + const y = bottomBaseline - below * lineHeight; + let slot: ParagraphLineSlot; + if (text.length === 0) { + slot = emptySlot(y, leftX, run, fallbackFamily); + newMemberPtrs.push(0); + newMemberFs.push(y); + } else { + const emittedTexts: string[] = []; + const ptrs = emitTextLine({ + doc, + page, + text, + x: leftX, + y, + fontSize: run.fontSize, + fill: run.fill, + ...inkFromRun(run), + originalFontPtr: 0, + charSpacingPt: run.charSpacingPt, + fallbackFamily, + outTexts: emittedTexts, + }); + this.lineEdit.createdPtrs.push(...ptrs); + newLeaf.push(...ptrs); + newMemberPtrs.push(ptrs[0] ?? 0); + newMemberFs.push(y); + slot = buildSlotForLine( + m, + ptrs, + text, + y, + leftX, + run, + fallbackFontIdFor(fallbackFamily), + emittedTexts, + ); + } + slot.startChar = cursor; + slot.endChar = cursor + text.length; + cursor += text.length; + newSlots.push(slot); + } + + // Preserve EVERY original object (fonts + layout intact); only append the + // new lines. ReflowWrapCommand re-lines the whole paragraph on blur. + run.paragraphLineSlots = [...slots.map(cloneSlot), ...newSlots]; + run.paragraphLeafPtrs = [...run.paragraphLeafPtrs, ...newLeaf]; + run.paragraphLeafContainers = [ + ...run.paragraphLeafContainers, + ...newLeaf.map(() => 0), + ]; + run.paragraphMemberPtrs = [...run.paragraphMemberPtrs, ...newMemberPtrs]; + run.paragraphMemberContainers = [ + ...run.paragraphMemberContainers, + ...newMemberPtrs.map(() => 0), + ]; + run.paragraphMemberFs = [...run.paragraphMemberFs, ...newMemberFs]; + run.paragraphLineHeight = lineHeight; + run.bounds = { + ...run.bounds, + y: bottomBaseline - below * lineHeight - run.fontSize * 0.25, + height: run.bounds.height + below * lineHeight, + }; + } + + // After an undo of a partial/paragraph edit, re-register the run's live + // PDFium objects as a flat overlay model. + private rebuildAsOverlayModel( + doc: EditorDocument, + page: Page, + run: TextRun, + lines: RebuildLine[], + fallbackFamily: string, + ): void { + const m = doc.module; + // Drop everything the run still owns that this rebuild is not keeping. + // A coalesced burst reverts several commands in a row, each re-emitting + // the whole run, so the earlier reverts' objects would stay painted under + // the later ones (5 objects -> 15 -> 48 on four characters). + // + // Both lists: the paragraph path tracks paragraphLeafPtrs, the partial + // (split) path repoints mergedFromPtrs at what it emitted. + const keep = new Set(); + for (const line of lines) { + for (const sr of line.subRuns) { + if (!sr.removed && sr.ptr) keep.add(sr.ptr); + } + } + for (const ptr of [...run.paragraphLeafPtrs, ...run.mergedFromPtrs]) { + if (!ptr || keep.has(ptr)) continue; + try { + m.FPDFPage_RemoveObject(page.pagePtr, ptr); + } catch { + /* best-effort - the ptr may already be gone */ + } + } + + const orderedLive: number[] = []; + const lineAnchors: number[] = []; + const anchorFs: number[] = []; + for (const line of lines) { + const slotLive: number[] = []; + for (const sr of line.subRuns) { + if (sr.removed) { + if (!sr.text) continue; + const ptrs = emitTextLine({ + doc, + page, + text: sr.text, + x: sr.x, + y: line.baselineY, + fontSize: line.fontSize, + fill: run.fill, + ...inkFromRun(run), + originalFontPtr: 0, + charSpacingPt: run.charSpacingPt, + fallbackFamily, + }); + slotLive.push(...ptrs); + } else if (sr.ptr) { + slotLive.push(sr.ptr); + } + } + if (slotLive.length === 0) continue; + lineAnchors.push(slotLive[0]); + anchorFs.push(line.baselineY); + orderedLive.push(...slotLive); + } + run.mergedFromPtrs = []; + run.mergedFromTexts = []; + run.mergedFromBounds = []; + run.mergedFromCharStarts = []; + run.paragraphLineSlots = []; + run.paragraphLeafPtrs = orderedLive; + run.paragraphLeafContainers = orderedLive.map(() => 0); + run.paragraphMemberPtrs = lineAnchors; + run.paragraphMemberContainers = lineAnchors.map(() => 0); + run.paragraphMemberFs = anchorFs; + if (orderedLive.length > 0) run.pdfiumObjPtr = orderedLive[0]; + } + + describe(): string { + return `Type into ${this.runId}`; + } + + /** Consecutive typing on the SAME run coalesces into one undo step. */ + coalesceKey(): string { + return `edit-text:${this.pageIndex}:${this.runId}`; + } + + /** The text this edit produced - lets the history compare adjacent edits. */ + get resultText(): string { + return this.nextText; + } + + // True when this edit's ENTIRE delta was one or more line breaks, i.e. the + // user pressed Enter and changed nothing else. + private isLineBreakOnlyInsertion(): boolean { + if (this.prevText === null) return false; + const inserted = insertedChunk(this.prevText, this.nextText); + return inserted !== null && /^(?:\r?\n)+$/.test(inserted); + } + + // "Press Enter, then type" is ONE logical action, so it must cost one undo - + // which is what makes a bare line break merge forward here. + coalesceIgnoresTimeWindow(previous: Command | null): boolean { + if (!(previous instanceof EditTextCommand)) return false; + if (this.prevText === null) return false; + // Contiguity: this edit must start from exactly what that one produced. + if (previous.resultText !== this.prevText) return false; + return previous.isLineBreakOnlyInsertion(); + } +} + +// The text `next` adds to `prev` when the change is a pure insertion at a +// single point, or null when it is anything else. +function insertedChunk(prev: string, next: string): string | null { + if (next.length <= prev.length) return null; + let head = 0; + while (head < prev.length && prev[head] === next[head]) head++; + let tail = 0; + while ( + tail < prev.length - head && + prev[prev.length - 1 - tail] === next[next.length - 1 - tail] + ) { + tail++; + } + // Everything outside the inserted chunk must be untouched original text. + if (head + tail !== prev.length) return null; + return next.slice(head, next.length - tail); +} + +/** Keep a run's model width from claiming space past the page's right edge. */ +function clampWidthToPage(x: number, width: number, page: Page): number { + // x/width are RAW PDF space, so the right edge is the CropBox right edge in + // raw space. + const rawRightEdge = page.display.cropLeft + page.display.cropWidth; + const maxWidth = Math.max(0, rawRightEdge - x); + return Math.min(width, maxWidth); +} + +function safeGetFont( + m: import("@embedpdf/pdfium").WrappedPdfiumModule, + objPtr: number, +): number { + const fn = (m as unknown as { FPDFTextObj_GetFont?: (p: number) => number }) + .FPDFTextObj_GetFont; + if (!fn) return 0; + try { + return fn(objPtr); + } catch { + return 0; + } +} + +function snapshotRevertLines( + run: import("@app/tools/pdfTextEditor/model/TextRun").TextRun, + prevText: string, +): RevertLine[] { + const lines = prevText.split(/\r?\n/); + const lineHeight = + run.paragraphLineHeight > 0 ? run.paragraphLineHeight : run.fontSize * 1.2; + return lines.map((text, idx) => ({ + text, + x: run.matrix.e, + y: run.matrix.f - idx * lineHeight, + fill: { ...run.fill }, + fontSize: Math.max(4, run.fontSize), + charSpacingPt: run.charSpacingPt, + })); +} + +// Reconstruct `paragraphLineSlots` from the data the overlay loop just emitted. +function buildSlotsFromOverlayEmit( + m: import("@embedpdf/pdfium").WrappedPdfiumModule, + run: import("@app/tools/pdfTextEditor/model/TextRun").TextRun, + perLineEmits: Array<{ + ptrs: number[]; + texts: string[]; + text: string; + x: number; + y: number; + }>, + fontId: string, +): import("@app/tools/pdfTextEditor/model/TextRun").ParagraphLineSlot[] { + const slots = []; + let cursor = 0; + for (const emit of perLineEmits) { + const text = emit.text; + const startChar = cursor; + const endChar = startChar + text.length; + // Empty-line slot: no PDFium sub-objects, no bounds. matrixE + baselineY + // carry the expected anchor for the next edit. + if (emit.ptrs.length === 0 || text.length === 0) { + slots.push({ + startChar, + endChar, + baselineY: emit.y, + matrixE: emit.x, + containerPtr: 0, + fontId, + fontSize: run.fontSize, + fontSubset: false, + mergedFromPtrs: [], + mergedFromTexts: [], + mergedFromBounds: [], + mergedFromCharStarts: [], + }); + cursor = endChar + 1; + continue; + } + const mergedFromTexts: string[] = []; + const mergedFromPtrs: number[] = []; + const mergedFromBounds: Array<{ x: number; right: number }> = []; + const mergedFromCharStarts: number[] = []; + if (emit.texts.length === emit.ptrs.length) { + // The emitter told us what each ptr carries. Never re-derive it: it emits + // per word OR per character, and the word guess below silently dropped + // every ptr past the word count, leaving those glyphs painted forever. + let at = 0; + for (let i = 0; i < emit.ptrs.length; i++) { + const piece = emit.texts[i]; + const found = text.indexOf(piece, at); + const start = found >= 0 ? found : at; + mergedFromPtrs.push(emit.ptrs[i]); + mergedFromTexts.push(piece); + mergedFromBounds.push(boundsFromPtr(m, emit.ptrs[i], run.matrix.e)); + mergedFromCharStarts.push(start); + at = start + piece.length; + } + } else if (emit.ptrs.length === 1) { + mergedFromPtrs.push(emit.ptrs[0]); + mergedFromTexts.push(text); + mergedFromBounds.push(boundsFromPtr(m, emit.ptrs[0], run.matrix.e)); + mergedFromCharStarts.push(0); + } else { + const words = text.split(/(\s+)/).filter((w) => w.length > 0); + const nonGapWords = words.filter((w) => !/^\s+$/.test(w)); + const used = Math.min(emit.ptrs.length, nonGapWords.length); + let cur = 0; + let wordIdx = 0; + for (let i = 0; i < words.length; i++) { + const w = words[i]; + if (/^\s+$/.test(w)) { + cur += w.length; + continue; + } + if (wordIdx >= used) { + cur += w.length; + wordIdx += 1; + continue; + } + const ptr = emit.ptrs[wordIdx]; + mergedFromPtrs.push(ptr); + mergedFromTexts.push(w); + mergedFromBounds.push(boundsFromPtr(m, ptr, run.matrix.e)); + mergedFromCharStarts.push(cur); + cur += w.length; + wordIdx += 1; + } + } + slots.push({ + startChar, + endChar, + baselineY: emit.y, + matrixE: run.matrix.e, + containerPtr: 0, + fontId, + fontSize: run.fontSize, + fontSubset: false, + mergedFromPtrs, + mergedFromTexts, + mergedFromBounds, + mergedFromCharStarts, + }); + cursor = endChar + 1; // +1 for the "\n" separator + } + return slots; +} + +function boundsFromPtr( + m: import("@embedpdf/pdfium").WrappedPdfiumModule, + ptr: number, + fallbackX: number, +): { x: number; right: number } { + const l = m.pdfium.wasmExports.malloc(4); + const b = m.pdfium.wasmExports.malloc(4); + const r = m.pdfium.wasmExports.malloc(4); + const t = m.pdfium.wasmExports.malloc(4); + try { + if (!m.FPDFPageObj_GetBounds(ptr, l, b, r, t)) { + return { x: fallbackX, right: fallbackX }; + } + return { + x: m.pdfium.getValue(l, "float"), + right: m.pdfium.getValue(r, "float"), + }; + } finally { + m.pdfium.wasmExports.free(l); + m.pdfium.wasmExports.free(b); + m.pdfium.wasmExports.free(r); + m.pdfium.wasmExports.free(t); + } +} + +function keptLeadingBaselines( + lineCount: number, + match: Map, + slots: ParagraphLineSlot[], + topBaseline: number, + lineHeight: number, +): number[] { + const out: number[] = []; + let y = topBaseline; + for (let i = 0; i < lineCount; i++) { + if (i > 0) y -= stepBetween(i, match, slots, lineHeight); + out.push(y); + } + return out; +} + +const MIN_REAL_LEADING = 0.5; + +function stepBetween( + i: number, + match: Map, + slots: ParagraphLineSlot[], + lineHeight: number, +): number { + const above = match.get(i - 1); + const here = match.get(i); + if (above === undefined || here === undefined) return lineHeight; + if (here !== above + 1) return lineHeight; + const delta = slots[above]?.baselineY - slots[here]?.baselineY; + if (!Number.isFinite(delta)) return lineHeight; + return delta >= MIN_REAL_LEADING * lineHeight ? delta : lineHeight; +} + +function cloneSlot(s: ParagraphLineSlot): ParagraphLineSlot { + return { + startChar: s.startChar, + endChar: s.endChar, + baselineY: s.baselineY, + matrixE: s.matrixE, + containerPtr: s.containerPtr, + fontId: s.fontId, + fontSize: s.fontSize, + fontSubset: s.fontSubset, + mergedFromPtrs: [...s.mergedFromPtrs], + mergedFromTexts: [...s.mergedFromTexts], + mergedFromBounds: s.mergedFromBounds.map((b) => ({ ...b })), + mergedFromCharStarts: [...s.mergedFromCharStarts], + }; +} + +/** + * A blank line, inheriting its font from the line it was split off. + * + * Without `seed` the slot is stamped base-14 the moment Enter is pressed, and + * everything typed into it afterwards re-emits against that - so a new line + * came out in Helvetica while the paragraph around it kept the document's own + * face. The blank line has no glyphs of its own to judge by, so the only honest + * default is the font of the line it came from. + */ +export function emptySlot( + baselineY: number, + leftX: number, + run: TextRun, + fallbackFamily: string, + seed?: ParagraphLineSlot, +): ParagraphLineSlot { + return { + startChar: 0, + endChar: 0, + baselineY, + matrixE: leftX, + containerPtr: 0, + fontId: seed ? seed.fontId : fallbackFontIdFor(fallbackFamily), + fontSize: run.fontSize, + fontSubset: seed ? seed.fontSubset : false, + mergedFromPtrs: [], + mergedFromTexts: [], + mergedFromBounds: [], + mergedFromCharStarts: [], + }; +} + +/** Build a slot for a freshly-emitted line, mapping each ptr to its word. */ +// Map the objects an emit produced back onto the line's text. +// +// One object per WORD is only what the base-14 path happens to produce; reusing +// an embedded font can route through the per-character branch instead, and +// assuming word alignment then filled the slot with empty sub-run texts and +// out-of-range char starts, which the NEXT edit's diff silently mis-sliced. +// `emitTextLine` reports what it wrote via outTexts; falling back to a text-page +// read would cost a full page extraction per line. +function sliceLineAcrossPtrs( + ptrs: number[], + text: string, + emitted?: string[], +): Array<{ text: string; start: number }> { + const out: Array<{ text: string; start: number }> = []; + if (emitted && emitted.length === ptrs.length) { + let cursor = 0; + for (const chunk of emitted) { + const at = chunk.length > 0 ? text.indexOf(chunk, cursor) : -1; + const start = at >= 0 ? at : cursor; + out.push({ text: chunk, start }); + cursor = start + chunk.length; + } + return out; + } + // No report from the emit: assume the base-14 shape, one object per word. + const words: Array<{ text: string; start: number }> = []; + const re = /\S+/g; + let wm: RegExpExecArray | null; + while ((wm = re.exec(text)) !== null) { + words.push({ text: wm[0], start: wm.index }); + } + for (let i = 0; i < ptrs.length; i += 1) { + const w = words[i]; + out.push(w ? { ...w } : { text: "", start: text.length }); + } + return out; +} + +function buildSlotForLine( + m: import("@embedpdf/pdfium").WrappedPdfiumModule, + ptrs: number[], + text: string, + baselineY: number, + leftX: number, + run: TextRun, + fontId: string, + emitted?: string[], +): ParagraphLineSlot { + const mergedFromPtrs: number[] = []; + const mergedFromTexts: string[] = []; + const mergedFromBounds: Array<{ x: number; right: number }> = []; + const mergedFromCharStarts: number[] = []; + const words = sliceLineAcrossPtrs(ptrs, text, emitted); + for (let i = 0; i < ptrs.length; i++) { + const w = words[i]; + const b = boundsFromPtr(m, ptrs[i], leftX); + mergedFromPtrs.push(ptrs[i]); + mergedFromTexts.push(w ? w.text : ""); + mergedFromBounds.push({ x: b.x, right: b.right }); + mergedFromCharStarts.push(w ? w.start : text.length); + } + return { + startChar: 0, + endChar: text.length, + baselineY, + matrixE: leftX, + containerPtr: 0, + fontId, + fontSize: run.fontSize, + fontSubset: false, + mergedFromPtrs, + mergedFromTexts, + mergedFromBounds, + mergedFromCharStarts, + }; +} + +function snapshotRunModel(run: TextRun): RunModelSnapshot { + return { + text: run.text, + matrixE: run.matrix.e, + matrixF: run.matrix.f, + bounds: { ...run.bounds }, + paragraphLineHeight: run.paragraphLineHeight, + paragraphMemberPtrs: [...run.paragraphMemberPtrs], + paragraphMemberContainers: [...run.paragraphMemberContainers], + paragraphMemberFs: [...run.paragraphMemberFs], + paragraphLeafPtrs: [...run.paragraphLeafPtrs], + paragraphLeafContainers: [...run.paragraphLeafContainers], + paragraphLineSlots: run.paragraphLineSlots.map(cloneSlot), + mergedFromPtrs: [...run.mergedFromPtrs], + mergedFromTexts: [...run.mergedFromTexts], + mergedFromBounds: run.mergedFromBounds.map((b) => ({ ...b })), + mergedFromCharStarts: [...run.mergedFromCharStarts], + fontId: run.fontId, + fontSubset: run.fontSubset, + pdfiumObjPtr: run.pdfiumObjPtr, + }; +} + +function restoreRunModel(run: TextRun, snap: RunModelSnapshot): void { + run.matrix = { ...run.matrix, e: snap.matrixE, f: snap.matrixF }; + run.bounds = { ...snap.bounds }; + run.paragraphLineHeight = snap.paragraphLineHeight; + run.paragraphMemberPtrs = [...snap.paragraphMemberPtrs]; + run.paragraphMemberContainers = [...snap.paragraphMemberContainers]; + run.paragraphMemberFs = [...snap.paragraphMemberFs]; + run.paragraphLeafPtrs = [...snap.paragraphLeafPtrs]; + run.paragraphLeafContainers = [...snap.paragraphLeafContainers]; + run.paragraphLineSlots = snap.paragraphLineSlots.map(cloneSlot); + run.mergedFromPtrs = [...snap.mergedFromPtrs]; + run.mergedFromTexts = [...snap.mergedFromTexts]; + run.mergedFromBounds = snap.mergedFromBounds.map((b) => ({ ...b })); + run.mergedFromCharStarts = [...snap.mergedFromCharStarts]; + run.fontId = snap.fontId; + run.fontSubset = snap.fontSubset; + run.pdfiumObjPtr = snap.pdfiumObjPtr; +} + +/** LCS over lines: maps next-line index -> matched prev-line index. */ +function lineLCS(a: string[], b: string[]): Map { + const m = a.length; + const n = b.length; + const dp: Int32Array[] = new Array(m + 1); + for (let i = 0; i <= m; i++) dp[i] = new Int32Array(n + 1); + for (let i = 1; i <= m; i++) { + for (let j = 1; j <= n; j++) { + dp[i][j] = + a[i - 1] === b[j - 1] + ? dp[i - 1][j - 1] + 1 + : Math.max(dp[i - 1][j], dp[i][j - 1]); + } + } + const map = new Map(); + let i = m; + let j = n; + while (i > 0 && j > 0) { + if (a[i - 1] === b[j - 1]) { + map.set(j - 1, i - 1); + i--; + j--; + } else if (dp[i - 1][j] >= dp[i][j - 1]) { + i--; + } else { + j--; + } + } + return map; +} + +/** Rebuild the flat leaf arrays from the run's slots. */ +function reflattenLeafArrays(run: TextRun): void { + const leaf: number[] = []; + const leafContainers: number[] = []; + for (const s of run.paragraphLineSlots) { + for (const p of s.mergedFromPtrs) { + leaf.push(p); + leafContainers.push(s.containerPtr); + } + } + run.paragraphLeafPtrs = leaf; + run.paragraphLeafContainers = leafContainers; +} + +/** Replace a restored slot (matched by baseline) with re-emitted objects. */ +function patchSlotPtrsByBaseline( + m: import("@embedpdf/pdfium").WrappedPdfiumModule, + run: TextRun, + baselineY: number, + ptrs: number[], + text: string, +): void { + const idx = run.paragraphLineSlots.findIndex( + (s) => Math.abs(s.baselineY - baselineY) < 1, + ); + if (idx < 0) return; + const old = run.paragraphLineSlots[idx]; + const rebuilt = buildSlotForLine( + m, + ptrs, + text, + baselineY, + old.matrixE, + run, + old.fontId, + ); + rebuilt.startChar = old.startChar; + rebuilt.endChar = old.endChar; + run.paragraphLineSlots[idx] = rebuilt; +} diff --git a/frontend/editor/src/core/tools/pdfTextEditor/commands/InsertImageCommand.ts b/frontend/editor/src/core/tools/pdfTextEditor/commands/InsertImageCommand.ts new file mode 100644 index 0000000000..babe778bdb --- /dev/null +++ b/frontend/editor/src/core/tools/pdfTextEditor/commands/InsertImageCommand.ts @@ -0,0 +1,203 @@ +import type { Command } from "@app/tools/pdfTextEditor/commands/Command"; +import type { EditorDocument } from "@app/tools/pdfTextEditor/model/EditorDocument"; +import { ImageObject } from "@app/tools/pdfTextEditor/model/ImageObject"; +import type { Affine } from "@app/tools/pdfTextEditor/types"; +import type { WrappedPdfiumModule } from "@embedpdf/pdfium"; +import { + counterPageRotation, + rotateObjectAbout, +} from "@app/tools/pdfTextEditor/commands/editTextHelpers"; +import { imageMatrixBounds } from "@app/tools/pdfTextEditor/model/affine"; +import { + embedBitmapImageOnPage, + embedJpegImageOnPage, +} from "@app/utils/pdfiumBitmapUtils"; + +// Insert a decoded raster image onto a page at the given lower-left coordinate, +// scaled to `(width, height)` PDF points. +export class InsertImageCommand implements Command { + readonly type = "insert-image"; + private readonly pageIndex: number; + private readonly rgba: Uint8ClampedArray; + private readonly pixelWidth: number; + private readonly pixelHeight: number; + private readonly x: number; + private readonly y: number; + private readonly width: number; + private readonly height: number; + /** Original JPEG bytes; when present, embedded as-is (DCTDecode) to keep the file small. */ + private readonly jpegBytes?: Uint8Array; + private createdImageId: string | null; + private createdObjPtr: number; + /** Matrix written on first embed; reused so redo re-inserts the same object. */ + private appliedMatrix: Affine | null; + + constructor(opts: { + pageIndex: number; + rgba: Uint8ClampedArray; + pixelWidth: number; + pixelHeight: number; + x: number; + y: number; + width: number; + height: number; + jpegBytes?: Uint8Array; + }) { + this.pageIndex = opts.pageIndex; + this.rgba = opts.rgba; + this.pixelWidth = opts.pixelWidth; + this.pixelHeight = opts.pixelHeight; + this.x = opts.x; + this.y = opts.y; + this.width = opts.width; + this.height = opts.height; + this.jpegBytes = opts.jpegBytes; + this.createdImageId = null; + this.createdObjPtr = 0; + this.appliedMatrix = null; + } + + get insertedImageId(): string | null { + return this.createdImageId; + } + + apply(doc: EditorDocument): void { + const page = doc.page(this.pageIndex); + const m = doc.module; + // Redo: re-insert the SAME object detached by revert instead of re-embedding. + // The object was only detached (not destroyed), so this is safe and leak-free. + if (this.createdObjPtr) { + m.FPDFPage_InsertObject(page.pagePtr, this.createdObjPtr); + if (this.createdImageId) { + const restored = new ImageObject({ + id: this.createdImageId, + pageIndex: page.index, + pdfiumObjPtr: this.createdObjPtr, + bounds: { + x: this.x, + y: this.y, + width: this.width, + height: this.height, + }, + matrix: this.appliedMatrix ?? { + a: this.width, + b: 0, + c: 0, + d: this.height, + e: this.x, + f: this.y, + }, + }); + page.setImages([...page.images, restored]); + } + page.markDirty(); + page.markNeedsGenerate(); + return; + } + // JPEG sources embed as-is (DCTDecode) to keep the output small; fall back + // to the RGBA bitmap path if the JPEG API is unavailable or the load fails. + let newObjPtr = this.jpegBytes + ? embedJpegImageOnPage( + m, + doc.docPtr, + page.pagePtr, + this.jpegBytes, + this.x, + this.y, + this.width, + this.height, + ) + : 0; + if (!newObjPtr) { + newObjPtr = embedBitmapImageOnPage( + m, + doc.docPtr, + page.pagePtr, + { + rgba: new Uint8Array( + this.rgba.buffer, + this.rgba.byteOffset, + this.rgba.byteLength, + ), + width: this.pixelWidth, + height: this.pixelHeight, + }, + this.x, + this.y, + this.width, + this.height, + ); + } + if (!newObjPtr) return; + // On a /Rotate page, counter-rotate about the centre so the image reads + // upright (mirrors InsertTextCommand); no-op on an unrotated page. + const rot = counterPageRotation(page.display.rotate); + const cx = this.x + this.width / 2; + const cy = this.y + this.height / 2; + if (rot) rotateObjectAbout(m, newObjPtr, cx, cy, rot.cos, rot.sin); + const matrix: Affine = rot + ? readMatrix(m, newObjPtr) + : { + a: this.width, + b: 0, + c: 0, + d: this.height, + e: this.x, + f: this.y, + }; + this.appliedMatrix = matrix; + const imageId = `p${page.index}-new-img-${page.images.length}-${newObjPtr}`; + const created = new ImageObject({ + id: imageId, + pageIndex: page.index, + pdfiumObjPtr: newObjPtr, + // On a /Rotate page the counter-rotated object's real AABB has swapped + // width/height vs the pre-rotation rect. + bounds: rot + ? imageMatrixBounds(matrix) + : { + x: this.x, + y: this.y, + width: this.width, + height: this.height, + }, + matrix, + }); + page.setImages([...page.images, created]); + page.markDirty(); + page.markNeedsGenerate(); + this.createdImageId = imageId; + this.createdObjPtr = newObjPtr; + } + + revert(doc: EditorDocument): void { + if (!this.createdObjPtr) return; + const page = doc.page(this.pageIndex); + doc.module.FPDFPage_RemoveObject(page.pagePtr, this.createdObjPtr); + if (this.createdImageId) { + page.setImages(page.images.filter((i) => i.id !== this.createdImageId)); + } + page.markDirty(); + page.markNeedsGenerate(); + } +} + +/** Read an object's current matrix so the model stays in lock-step with PDFium. */ +function readMatrix(m: WrappedPdfiumModule, objPtr: number): Affine { + // FS_MATRIX: { a, b, c, d, e, f } as floats. + const buf = m.pdfium.wasmExports.malloc(6 * 4); + try { + const ok = m.FPDFPageObj_GetMatrix(objPtr, buf); + if (!ok) return { a: 1, b: 0, c: 0, d: 1, e: 0, f: 0 }; + return { + a: m.pdfium.getValue(buf, "float"), + b: m.pdfium.getValue(buf + 4, "float"), + c: m.pdfium.getValue(buf + 8, "float"), + d: m.pdfium.getValue(buf + 12, "float"), + e: m.pdfium.getValue(buf + 16, "float"), + f: m.pdfium.getValue(buf + 20, "float"), + }; + } finally { + m.pdfium.wasmExports.free(buf); + } +} diff --git a/frontend/editor/src/core/tools/pdfTextEditor/commands/InsertTextCommand.ts b/frontend/editor/src/core/tools/pdfTextEditor/commands/InsertTextCommand.ts new file mode 100644 index 0000000000..1756fc6565 --- /dev/null +++ b/frontend/editor/src/core/tools/pdfTextEditor/commands/InsertTextCommand.ts @@ -0,0 +1,133 @@ +import type { Command } from "@app/tools/pdfTextEditor/commands/Command"; +import type { EditorDocument } from "@app/tools/pdfTextEditor/model/EditorDocument"; +import { TextRun } from "@app/tools/pdfTextEditor/model/TextRun"; +import { BLACK } from "@app/tools/pdfTextEditor/model/Color"; +import { writeUtf16 } from "@app/services/pdfiumService"; +import { + counterPageRotation, + rotateObjectAbout, + sanitizeForBase14, +} from "@app/tools/pdfTextEditor/commands/editTextHelpers"; +import { emitFallbackTextObject } from "@app/tools/pdfTextEditor/util/fallbackFont"; + +const DEFAULT_FAMILY = "Helvetica"; +const DEFAULT_SIZE = 12; + +// Create a brand-new text object on the given page at the given page-space +// point. +export class InsertTextCommand implements Command { + readonly type = "insert-text"; + private readonly pageIndex: number; + private readonly x: number; + private readonly y: number; + private readonly text: string; + private createdRunId: string | null; + private createdObjPtr: number; + + constructor(opts: { + pageIndex: number; + x: number; + y: number; + text?: string; + }) { + this.pageIndex = opts.pageIndex; + this.x = opts.x; + this.y = opts.y; + this.text = opts.text ?? "Text"; + this.createdRunId = null; + this.createdObjPtr = 0; + } + + /** Returns the id of the run this command created, after apply. */ + get insertedRunId(): string | null { + return this.createdRunId; + } + + apply(doc: EditorDocument): void { + const page = doc.page(this.pageIndex); + const m = doc.module; + + // Base-14 (WinAnsi) can't render >U+00FF. + const sanitized = sanitizeForBase14(this.text); + let objPtr = 0; + if ([...this.text].length > [...sanitized].length) { + objPtr = emitFallbackTextObject( + doc, + page, + this.text, + DEFAULT_SIZE, + BLACK, + this.x, + this.y, + ); + } + if (!objPtr) { + objPtr = m.FPDFPageObj_NewTextObj( + doc.docPtr, + DEFAULT_FAMILY, + DEFAULT_SIZE, + ); + if (!objPtr) return; + const textPtr = writeUtf16(m, sanitized); + try { + m.FPDFText_SetText(objPtr, textPtr); + } finally { + m.pdfium.wasmExports.free(textPtr); + } + m.FPDFPageObj_SetFillColor(objPtr, BLACK.r, BLACK.g, BLACK.b, BLACK.a); + m.FPDFPageObj_Transform(objPtr, 1, 0, 0, 1, this.x, this.y); + m.FPDFPage_InsertObject(page.pagePtr, objPtr); + } + + // On a /Rotate page, counter-rotate the new object about its anchor so it + // reads upright in the displayed orientation rather than landing sideways. + const rot = counterPageRotation(page.display.rotate); + if (rot) rotateObjectAbout(m, objPtr, this.x, this.y, rot.cos, rot.sin); + const matrix = rot + ? { + a: rot.cos, + b: rot.sin, + c: -rot.sin, + d: rot.cos, + e: this.x, + f: this.y, + } + : { a: 1, b: 0, c: 0, d: 1, e: this.x, f: this.y }; + + const runId = `p${page.index}-new-${page.runs.length}-${objPtr}`; + const run = new TextRun({ + id: runId, + pageIndex: page.index, + pdfiumObjPtr: objPtr, + bounds: { + x: this.x, + y: this.y, + width: this.text.length * DEFAULT_SIZE * 0.6, + height: DEFAULT_SIZE * 1.2, + }, + matrix, + text: this.text, + fontId: `base14:${DEFAULT_FAMILY}`, + fontSize: DEFAULT_SIZE, + fill: { ...BLACK }, + fontSubset: false, + }); + page.setRuns([...page.runs, run]); + page.markDirty(); + page.markNeedsGenerate(); + + this.createdRunId = runId; + this.createdObjPtr = objPtr; + } + + revert(doc: EditorDocument): void { + if (!this.createdObjPtr) return; + const page = doc.page(this.pageIndex); + doc.module.FPDFPage_RemoveObject(page.pagePtr, this.createdObjPtr); + if (this.createdRunId) { + page.setRuns(page.runs.filter((r) => r.id !== this.createdRunId)); + } + page.markDirty(); + page.markNeedsGenerate(); + } +} diff --git a/frontend/editor/src/core/tools/pdfTextEditor/commands/MergeRunsCommand.ts b/frontend/editor/src/core/tools/pdfTextEditor/commands/MergeRunsCommand.ts new file mode 100644 index 0000000000..d0278233a5 --- /dev/null +++ b/frontend/editor/src/core/tools/pdfTextEditor/commands/MergeRunsCommand.ts @@ -0,0 +1,249 @@ +import type { Command } from "@app/tools/pdfTextEditor/commands/Command"; +import type { EditorDocument } from "@app/tools/pdfTextEditor/model/EditorDocument"; +import { + cloneParagraphLineSlot, + type ParagraphLineSlot, + type TextRun, +} from "@app/tools/pdfTextEditor/model/TextRun"; +import { + buildLineSlotsFromDescriptors, + type LineSlotDescriptor, + medianLineHeightFromBaselines, +} from "@app/tools/pdfTextEditor/pdfium/ParagraphGrouper"; + +/** Merge the selected runs on a single page into one virtual paragraph. */ +interface RunSnapshot { + id: string; + pdfiumObjPtr: number; + matrixF: number; + containerPtr: number; + text: string; + paragraphLineHeight: number; + paragraphMemberPtrs: number[]; + paragraphMemberContainers: number[]; + paragraphMemberFs: number[]; + paragraphLeafPtrs: number[]; + paragraphLeafContainers: number[]; + paragraphLineSlots: ParagraphLineSlot[]; + bounds: { x: number; y: number; width: number; height: number }; +} + +export class MergeRunsCommand implements Command { + readonly type = "merge-runs"; + private readonly pageIndex: number; + private readonly runIds: string[]; + private removedRunSnapshots: RunSnapshot[] = []; + // The TextRun instances we removed from page.runs at apply time. + private removedRunInstances: TextRun[] = []; + // Original `page.runs` order at apply time so revert restores the + // ordering callers depend on (z-order, find-bar iteration order). + private prevRunOrder: string[] = []; + private repPrev: RunSnapshot | null = null; + private repId: string | null = null; + + constructor(opts: { pageIndex: number; runIds: string[] }) { + this.pageIndex = opts.pageIndex; + this.runIds = [...opts.runIds]; + } + + get representativeRunId(): string | null { + return this.repId; + } + + apply(doc: EditorDocument): void { + if (this.runIds.length < 2) return; + const page = doc.page(this.pageIndex); + const runs = this.runIds + .map((id) => page.findRun(id)) + .filter((r): r is TextRun => !!r); + if (runs.length < 2) return; + + runs.sort((a, b) => b.matrix.f - a.matrix.f); + const rep = runs[0]; + const members = runs.slice(1); + this.repId = rep.id; + this.repPrev = snapshotRun(rep); + this.removedRunSnapshots = members.map(snapshotRun); + this.removedRunInstances = members; + this.prevRunOrder = page.runs.map((r) => r.id); + + // A selected run may itself be a multi-line paragraph rep, so flatten the + // runs into ONE descriptor per visual line before building slots/members. + const descs: LineDescriptor[] = []; + for (const r of runs) descs.push(...flattenRunToLines(r)); + + const minX = Math.min(...runs.map((r) => r.bounds.x)); + const maxRight = Math.max(...runs.map((r) => r.bounds.x + r.bounds.width)); + const topY = Math.max(...runs.map((r) => r.bounds.y + r.bounds.height)); + const bottomY = Math.min(...runs.map((r) => r.bounds.y)); + + rep.text = descs.map((d) => d.text).join("\n"); + rep.bounds = { + x: minX, + y: bottomY, + width: maxRight - minX, + height: topY - bottomY, + }; + // Median of consecutive per-line baseline deltas, not the rep-top-only + // formula, so multi-line reps keep correct spacing. + rep.paragraphLineHeight = + descs.length > 1 + ? medianLineHeightFromBaselines( + descs.map((d) => d.baselineY), + rep.fontSize, + ) + : rep.paragraphLineHeight || rep.fontSize * 1.2; + rep.paragraphMemberPtrs = descs.map((d) => d.leafPtrs[0] ?? 0); + rep.paragraphMemberContainers = descs.map((d) => d.containerPtr); + rep.paragraphMemberFs = descs.map((d) => d.baselineY); + // Flatten each line's own merged sub-ptrs so EditTextCommand removes + // every original sub-word, not just the first ptr of each line. + const leafPtrs: number[] = []; + const leafContainers: number[] = []; + for (const d of descs) { + for (const p of d.leafPtrs) { + leafPtrs.push(p); + leafContainers.push(d.containerPtr); + } + } + rep.paragraphLeafPtrs = leafPtrs; + rep.paragraphLeafContainers = leafContainers; + // Per-line slots so a later partial edit keeps each line's source font + // (planParagraphEdit bails without them, falling back to Helvetica). + rep.paragraphLineSlots = buildLineSlotsFromDescriptors(descs); + + const removedIds = new Set(members.map((r) => r.id)); + page.setRuns(page.runs.filter((r) => !removedIds.has(r.id))); + // Bump the page revision so the dirty-only resnapshot in EditorStore + // republishes this page. + page.markDirty(); + } + + revert(doc: EditorDocument): void { + if (!this.repId || !this.repPrev) return; + const page = doc.page(this.pageIndex); + const rep = page.findRun(this.repId); + if (rep) restoreRun(rep, this.repPrev); + + // Re-attach the member TextRun instances we held aside at apply time. + const byId = new Map(); + for (const r of page.runs) byId.set(r.id, r); + for (const r of this.removedRunInstances) { + if (!byId.has(r.id)) byId.set(r.id, r); + } + const ordered: TextRun[] = []; + const seen = new Set(); + for (const id of this.prevRunOrder) { + const r = byId.get(id); + if (r) { + ordered.push(r); + seen.add(id); + } + } + for (const r of page.runs) { + if (!seen.has(r.id)) { + ordered.push(r); + seen.add(r.id); + } + } + page.setRuns(ordered); + page.markDirty(); + } + + describe(): string { + return `Merge ${this.runIds.length} runs into a paragraph`; + } +} + +// A descriptor is a slot source (mergedFrom* for the slot) plus the line's +// real leaf ptrs (which can differ from the slot fallback for single-line runs). +interface LineDescriptor extends LineSlotDescriptor { + leafPtrs: number[]; +} + +/** Expand a run into one descriptor per visual line. */ +function flattenRunToLines(r: TextRun): LineDescriptor[] { + if (r.paragraphLineSlots.length >= 2) { + return r.paragraphLineSlots.map((slot) => ({ + text: r.text.slice(slot.startChar, slot.endChar), + baselineY: slot.baselineY, + matrixE: slot.matrixE, + containerPtr: slot.containerPtr, + fontId: slot.fontId, + fontSize: slot.fontSize, + fontSubset: slot.fontSubset, + mergedFromPtrs: [...slot.mergedFromPtrs], + mergedFromTexts: [...slot.mergedFromTexts], + mergedFromBounds: slot.mergedFromBounds.map((b) => ({ ...b })), + mergedFromCharStarts: [...slot.mergedFromCharStarts], + leafPtrs: [...slot.mergedFromPtrs], + })); + } + const leafPtrs = + r.paragraphLeafPtrs.length > 0 + ? [...r.paragraphLeafPtrs] + : r.mergedFromPtrs.length > 0 + ? [...r.mergedFromPtrs] + : r.pdfiumObjPtr + ? [r.pdfiumObjPtr] + : []; + // Slot sub-runs mirror buildLineSlots' single-line fallback so partial edits + // keep the source font instead of bailing to the overlay path. + const hasSubRuns = r.mergedFromPtrs.length > 0; + const mergedFromPtrs = hasSubRuns + ? [...r.mergedFromPtrs] + : r.pdfiumObjPtr + ? [r.pdfiumObjPtr] + : []; + const mergedFromTexts = hasSubRuns ? [...r.mergedFromTexts] : [r.text]; + const mergedFromBounds = hasSubRuns + ? r.mergedFromBounds.map((b) => ({ ...b })) + : [{ x: r.bounds.x, right: r.bounds.x + r.bounds.width }]; + const mergedFromCharStarts = hasSubRuns ? [...r.mergedFromCharStarts] : [0]; + return [ + { + text: r.text, + baselineY: r.matrix.f, + matrixE: r.matrix.e, + containerPtr: r.containerPtr, + fontId: r.fontId, + fontSize: r.fontSize, + fontSubset: r.fontSubset, + mergedFromPtrs, + mergedFromTexts, + mergedFromBounds, + mergedFromCharStarts, + leafPtrs, + }, + ]; +} + +function snapshotRun(r: TextRun): RunSnapshot { + return { + id: r.id, + pdfiumObjPtr: r.pdfiumObjPtr, + matrixF: r.matrix.f, + containerPtr: r.containerPtr, + text: r.text, + paragraphLineHeight: r.paragraphLineHeight, + paragraphMemberPtrs: [...r.paragraphMemberPtrs], + paragraphMemberContainers: [...r.paragraphMemberContainers], + paragraphMemberFs: [...r.paragraphMemberFs], + paragraphLeafPtrs: [...r.paragraphLeafPtrs], + paragraphLeafContainers: [...r.paragraphLeafContainers], + paragraphLineSlots: r.paragraphLineSlots.map(cloneParagraphLineSlot), + bounds: { ...r.bounds }, + }; +} + +function restoreRun(r: TextRun, snap: RunSnapshot): void { + r.text = snap.text; + r.bounds = { ...snap.bounds }; + r.paragraphLineHeight = snap.paragraphLineHeight; + r.paragraphMemberPtrs = [...snap.paragraphMemberPtrs]; + r.paragraphMemberContainers = [...snap.paragraphMemberContainers]; + r.paragraphMemberFs = [...snap.paragraphMemberFs]; + r.paragraphLeafPtrs = [...snap.paragraphLeafPtrs]; + r.paragraphLeafContainers = [...snap.paragraphLeafContainers]; + r.paragraphLineSlots = snap.paragraphLineSlots.map(cloneParagraphLineSlot); +} diff --git a/frontend/editor/src/core/tools/pdfTextEditor/commands/MoveTextRunCommand.ts b/frontend/editor/src/core/tools/pdfTextEditor/commands/MoveTextRunCommand.ts new file mode 100644 index 0000000000..2cf54ba7a9 --- /dev/null +++ b/frontend/editor/src/core/tools/pdfTextEditor/commands/MoveTextRunCommand.ts @@ -0,0 +1,100 @@ +import type { Command } from "@app/tools/pdfTextEditor/commands/Command"; +import type { EditorDocument } from "@app/tools/pdfTextEditor/model/EditorDocument"; +import { collectMemberPtrs } from "@app/tools/pdfTextEditor/commands/editTextHelpers"; +import { transformObject } from "@app/tools/pdfTextEditor/util/objectTransform"; + +/** Translate a text run by (dx, dy) in PDF page-space points. */ +export class MoveTextRunCommand implements Command { + readonly type = "move-text-run"; + private readonly pageIndex: number; + private readonly runId: string; + private readonly dx: number; + private readonly dy: number; + private appliedPtrs: number[]; + + constructor(opts: { + pageIndex: number; + runId: string; + dx: number; + dy: number; + }) { + this.pageIndex = opts.pageIndex; + this.runId = opts.runId; + this.dx = opts.dx; + this.dy = opts.dy; + this.appliedPtrs = []; + } + + apply(doc: EditorDocument): void { + const page = doc.page(this.pageIndex); + const run = page.findRun(this.runId); + if (!run) return; + const m = doc.module; + const seen = new Set(); + for (const ptr of collectMemberPtrs(run)) { + if (!ptr || seen.has(ptr)) continue; + seen.add(ptr); + try { + transformObject(m, ptr, 1, 0, 0, 1, this.dx, this.dy); + this.appliedPtrs.push(ptr); + } catch { + /* skip leaks; revert only undoes the ptrs we actually moved */ + } + } + this.shiftModel(run, this.dx, this.dy); + run.dirty = true; + page.markDirty(); + page.markNeedsGenerate(); + } + + revert(doc: EditorDocument): void { + if (this.appliedPtrs.length === 0) return; + const page = doc.page(this.pageIndex); + const run = page.findRun(this.runId); + if (!run) return; + const m = doc.module; + for (const ptr of this.appliedPtrs) { + if (!ptr) continue; + try { + transformObject(m, ptr, 1, 0, 0, 1, -this.dx, -this.dy); + } catch { + /* best-effort */ + } + } + this.shiftModel(run, -this.dx, -this.dy); + run.dirty = true; + page.markDirty(); + page.markNeedsGenerate(); + this.appliedPtrs = []; + } + + /** Shift the run's matrix/bounds + per-line + sub-run model by (dx, dy). */ + private shiftModel( + run: import("@app/tools/pdfTextEditor/model/TextRun").TextRun, + dx: number, + dy: number, + ): void { + run.matrix = { ...run.matrix, e: run.matrix.e + dx, f: run.matrix.f + dy }; + run.bounds = { ...run.bounds, x: run.bounds.x + dx, y: run.bounds.y + dy }; + if (run.paragraphMemberFs.length > 0) { + run.paragraphMemberFs = run.paragraphMemberFs.map((f) => f + dy); + } + if (run.paragraphLineSlots.length > 0) { + run.paragraphLineSlots = run.paragraphLineSlots.map((s) => ({ + ...s, + baselineY: s.baselineY + dy, + matrixE: s.matrixE + dx, + mergedFromBounds: s.mergedFromBounds.map((b) => ({ + x: b.x + dx, + right: b.right + dx, + })), + })); + } + if (run.mergedFromBounds.length > 0) { + run.mergedFromBounds = run.mergedFromBounds.map((b) => ({ + x: b.x + dx, + right: b.right + dx, + })); + } + } +} diff --git a/frontend/editor/src/core/tools/pdfTextEditor/commands/ReflowWrapCommand.ts b/frontend/editor/src/core/tools/pdfTextEditor/commands/ReflowWrapCommand.ts new file mode 100644 index 0000000000..db18820d8d --- /dev/null +++ b/frontend/editor/src/core/tools/pdfTextEditor/commands/ReflowWrapCommand.ts @@ -0,0 +1,660 @@ +import type { Command } from "@app/tools/pdfTextEditor/commands/Command"; +import type { EditorDocument } from "@app/tools/pdfTextEditor/model/EditorDocument"; +import type { + ParagraphLineSlot, + TextRun, +} from "@app/tools/pdfTextEditor/model/TextRun"; +import type { WrappedPdfiumModule } from "@embedpdf/pdfium"; +import { readUtf16 } from "@app/services/pdfiumService"; +import { rotationFromMatrix } from "@app/tools/pdfTextEditor/commands/editTextHelpers"; +import { transformObject } from "@app/tools/pdfTextEditor/util/objectTransform"; + +/** Reflow a text run's EXISTING glyph objects to fit within `maxWidthPt`. */ + +interface Leaf { + ptr: number; + container: number; + text: string; + x: number; + right: number; + baseline: number; +} + +interface Word { + glyphs: Leaf[]; + x: number; + right: number; + baseline: number; +} + +interface RunSnapshot { + text: string; + matrixE: number; + matrixF: number; + bounds: { x: number; y: number; width: number; height: number }; + paragraphLineHeight: number; + paragraphMemberPtrs: number[]; + paragraphMemberContainers: number[]; + paragraphMemberFs: number[]; + paragraphLeafPtrs: number[]; + paragraphLeafContainers: number[]; + paragraphLineSlots: ParagraphLineSlot[]; + paragraphSoftStarts: boolean[]; + mergedFromPtrs: number[]; + mergedFromTexts: string[]; + mergedFromBounds: Array<{ x: number; right: number }>; + mergedFromCharStarts: number[]; + pdfiumObjPtr: number; +} + +export class ReflowWrapCommand implements Command { + readonly type = "reflow-wrap"; + private readonly pageIndex: number; + private readonly runId: string; + private readonly maxWidthPt: number; + private applied = false; + /** Per-object translation applied, so revert can undo it exactly. */ + private moves: Array<{ ptr: number; dx: number; dy: number }> = []; + private prev: RunSnapshot | null = null; + + constructor(opts: { pageIndex: number; runId: string; maxWidthPt: number }) { + this.pageIndex = opts.pageIndex; + this.runId = opts.runId; + this.maxWidthPt = opts.maxWidthPt; + } + + apply(doc: EditorDocument): void { + const page = doc.page(this.pageIndex); + const run = page.findRun(this.runId); + if (!run) return; + if (this.maxWidthPt <= 0) return; + // Reflow math is axis-aligned (advance +x, step -y); a rotated run reads + // along a rotated axis, so skip rather than scatter glyphs. + if (rotationFromMatrix(run.matrix)) return; + + const m = doc.module; + // Geometry + text must reflect the latest edits, and FPDFTextObj_GetText + // reads the content stream, so flush then load a text page. + page.flushGenerate(m); + const textPage = m.FPDFText_LoadPage(page.pagePtr); + let leaves: Leaf[]; + try { + leaves = extractLeaves(m, textPage, run); + } finally { + m.FPDFText_ClosePage(textPage); + } + if (leaves.length === 0) return; + + const fontSize = run.fontSize > 0 ? run.fontSize : 12; + const lineHeight = + run.paragraphLineHeight > 0 ? run.paragraphLineHeight : fontSize * 1.2; + const startX = Math.min(...leaves.map((l) => l.x)); + const topBaseline = Math.max(...leaves.map((l) => l.baseline)); + // Clamp the wrap width to the page measured from OUR OWN left edge - but + // to the edge itself, with no margin held back. The caller's width is the + // box the paragraph was already laid out in, so shaving a font-size margin + // off it wraps at LESS than the document's own measure and every line + // loses its last word: "...carry out various" drops "various" onto a line + // of its own, on lines the user never touched. + const rawRightEdge = page.display.cropLeft + page.display.cropWidth; + const maxWidth = Math.min( + this.maxWidthPt, + Math.max(fontSize * 4, rawRightEdge - startX), + ); + + // Reflow is only NEEDED when some line actually overflows the wrap width. + { + const rightByLine = new Map(); + for (const l of leaves) { + const key = Math.round(l.baseline / 2); + const prev = rightByLine.get(key); + if (prev === undefined || l.right > prev) rightByLine.set(key, l.right); + } + let overflows = false; + for (const right of rightByLine.values()) { + if (right - startX > maxWidth + 0.5) { + overflows = true; + break; + } + } + if (!overflows) return; + } + + const words = groupWords(leaves, fontSize * 0.18); + const spaceWidth = estimateSpaceWidth(words, fontSize); + // The gap that FOLLOWED this word in the document, when the next word was + // beside it on the same line. Justified text stretches its spaces line by + // line, so rebuilding every line on one median width makes the lines that + // were set tighter than the median come out wider than they were authored + // - and each one then drops its last word onto a line of its own, on lines + // the user never edited. Only a pair the reflow is genuinely joining for + // the first time needs the estimate. + const gapAfter = (index: number): number => { + const a = words[index]; + const b = words[index + 1]; + if (!a || !b) return spaceWidth; + if (Math.abs(a.baseline - b.baseline) > 2) return spaceWidth; + const gap = b.x - a.right; + return gap > 0 ? gap : spaceWidth; + }; + // Manual line breaks the user typed (Enter) live in run.text as "\n". + const hardBreaks = hardBreakNonWsCounts(run.text, run.paragraphSoftStarts); + + this.prev = snapshotRun(run); + + // Blank lines BEFORE the first word have no glyphs, so topBaseline (the + // highest glyph) is already the first CONTENT line. + const leadingBreaks = hardBreaks.get(0) ?? 0; + if (leadingBreaks > 0) hardBreaks.delete(0); + const virtualTop = topBaseline + leadingBreaks * lineHeight; + const lines: Word[][] = []; + const lineIsHardStart: boolean[] = []; + for (let k = 0; k < leadingBreaks; k++) { + lines.push([]); + lineIsHardStart.push(true); + } + lines.push([]); + // After a leading blank the content line starts at a HARD break, or the + // rebuilt text would join the blank and the content with a space. + lineIsHardStart.push(leadingBreaks > 0); + let cursorX = startX; + let lineIdx = lines.length - 1; + let cumNonWs = 0; + for (let wordIndex = 0; wordIndex < words.length; wordIndex++) { + const w = words[wordIndex]; + const width = w.right - w.x; + const wordNonWs = w.glyphs.reduce( + (n, g) => n + g.text.replace(/\s+/g, "").length, + 0, + ); + const breakCount = hardBreaks.get(cumNonWs) ?? 0; + const hardBreakHere = breakCount > 0; + // Consume the entry: a following word contributing zero non-ws chars + // (a standalone space object) must not re-apply the same break. + if (hardBreakHere) hardBreaks.delete(cumNonWs); + const widthBreak = + cursorX > startX && cursorX + width > startX + maxWidth; + if (hardBreakHere || widthBreak) { + // k consecutive newlines = k-1 blank lines + 1 content line; emit + // empties so an intentional blank line survives reflow. + for (let k = 1; k < breakCount; k++) { + lineIdx += 1; + lines.push([]); + lineIsHardStart.push(true); + } + lineIdx += 1; + lines.push([]); + lineIsHardStart.push(hardBreakHere); + cursorX = startX; + } + const targetX = cursorX; + const targetBaseline = virtualTop - lineIdx * lineHeight; + const dx = targetX - w.x; + const dy = targetBaseline - w.baseline; + if (Math.abs(dx) > 0.001 || Math.abs(dy) > 0.001) { + for (const g of w.glyphs) { + try { + transformObject(m, g.ptr, 1, 0, 0, 1, dx, dy); + } catch { + /* best-effort - stale ptr */ + } + this.moves.push({ ptr: g.ptr, dx, dy }); + g.x += dx; + g.right += dx; + g.baseline += dy; + } + } + lines[lineIdx].push(w); + cursorX = targetX + width + gapAfter(wordIndex); + cumNonWs += wordNonWs; + } + // Hard breaks AFTER the last word (Enter at paragraph end) were never + // reached by the loop, so blur silently deleted the trailing blank lines. + const trailingBreaks = hardBreaks.get(cumNonWs) ?? 0; + for (let k = 0; k < trailingBreaks; k++) { + lineIdx += 1; + lines.push([]); + lineIsHardStart.push(true); + } + + rebuildRunFromLines( + run, + lines, + lineIsHardStart, + startX, + virtualTop, + lineHeight, + fontSize, + this.prev.text, + ); + run.dirty = true; + page.markDirty(); + page.markNeedsGenerate(); + this.applied = true; + } + + revert(doc: EditorDocument): void { + if (!this.applied || !this.prev) return; + const page = doc.page(this.pageIndex); + const run = page.findRun(this.runId); + if (!run) return; + const m = doc.module; + for (let i = this.moves.length - 1; i >= 0; i--) { + const mv = this.moves[i]; + try { + transformObject(m, mv.ptr, 1, 0, 0, 1, -mv.dx, -mv.dy); + } catch { + /* best-effort */ + } + } + this.moves = []; + restoreRun(run, this.prev); + run.dirty = true; + page.markDirty(); + page.markNeedsGenerate(); + this.applied = false; + } + + describe(): string { + return `Wrap ${this.runId}`; + } + + // Share the edit coalesce key for this run so the auto-reflow that fires on + // blur merges into the preceding typing burst's single undo step. + coalesceKey(): string { + return `edit-text:${this.pageIndex}:${this.runId}`; + } + + // The gap between the last keystroke and the blur is the user's think-time, + // so the 600ms coalesce window must not apply here. + coalesceIgnoresTimeWindow(): boolean { + return true; + } +} + +/** Read every leaf object's ACTUAL geometry + text straight from PDFium. */ +function extractLeaves( + m: WrappedPdfiumModule, + textPage: number, + run: TextRun, +): Leaf[] { + let ptrs: number[]; + let containers: number[]; + if (run.paragraphLeafPtrs.length > 0) { + ptrs = run.paragraphLeafPtrs; + containers = run.paragraphLeafContainers; + } else { + ptrs = run.mergedFromPtrs; + containers = ptrs.map(() => run.containerPtr); + } + const leaves: Leaf[] = []; + const seen = new Set(); + for (let i = 0; i < ptrs.length; i++) { + const ptr = ptrs[i]; + if (!ptr || seen.has(ptr)) continue; + seen.add(ptr); + const b = readObjBounds(m, ptr); + if (!b) continue; + leaves.push({ + ptr, + container: containers[i] ?? 0, + text: readObjText(m, textPage, ptr), + x: b.x, + right: b.right, + baseline: readObjBaseline(m, ptr), + }); + } + // Reading order: top line first (higher baseline), then left-to-right. + leaves.sort((a, b) => { + if (Math.abs(a.baseline - b.baseline) > 2) return b.baseline - a.baseline; + return a.x - b.x; + }); + return leaves; +} + +/** Group consecutive same-baseline leaves into words. */ +function groupWords(leaves: Leaf[], gapThreshold: number): Word[] { + const words: Word[] = []; + let cur: Leaf[] = []; + let prev: Leaf | null = null; + const flush = () => { + if (cur.length === 0) return; + words.push({ + glyphs: cur, + x: Math.min(...cur.map((g) => g.x)), + right: Math.max(...cur.map((g) => g.right)), + baseline: cur[0].baseline, + }); + cur = []; + }; + for (const g of leaves) { + if (prev) { + const sameLine = Math.abs(g.baseline - prev.baseline) <= 2; + const gap = g.x - prev.right; + if (!sameLine || gap > gapThreshold) flush(); + } + cur.push(g); + prev = g; + } + flush(); + return words; +} + +/** The non-whitespace char counts at which `text` has a hard "\n" break. */ +function hardBreakNonWsCounts( + text: string, + softStarts?: boolean[], +): Map { + const out = new Map(); + let nonWs = 0; + let lineIndex = 0; + for (const ch of text) { + if (ch === "\n") { + lineIndex += 1; + // A break this command inserted to make the text fit is not the user's, + // so it must stay re-flowable. Reading it back as forced would freeze the + // paragraph at whatever width it happened to be wrapped to. + if (!softStarts?.[lineIndex]) out.set(nonWs, (out.get(nonWs) ?? 0) + 1); + } else if (!/\s/.test(ch)) nonWs += 1; + } + return out; +} + +/** Median inter-word gap on the original lines; falls back to ~0.3em. */ +function estimateSpaceWidth(words: Word[], fontSize: number): number { + const gaps: number[] = []; + for (let i = 1; i < words.length; i++) { + const a = words[i - 1]; + const b = words[i]; + if (Math.abs(a.baseline - b.baseline) <= 2) { + const gap = b.x - a.right; + if (gap > 0) gaps.push(gap); + } + } + if (gaps.length === 0) return fontSize * 0.3; + gaps.sort((x, y) => x - y); + return gaps[Math.floor(gaps.length / 2)]; +} + +function rebuildRunFromLines( + run: TextRun, + lines: Word[][], + lineIsHardStart: boolean[], + startX: number, + topBaseline: number, + lineHeight: number, + fontSize: number, + preReflowText: string, +): void { + const slots: ParagraphLineSlot[] = []; + const lineTexts: string[] = []; + const leafPtrs: number[] = []; + const leafContainers: number[] = []; + const memberPtrs: number[] = []; + const memberContainers: number[] = []; + const memberFs: number[] = []; + let cursorChar = 0; + let maxRight = startX; + + for (let li = 0; li < lines.length; li++) { + const lineWords = lines[li]; + const baseline = topBaseline - li * lineHeight; + const mergedFromPtrs: number[] = []; + const mergedFromTexts: string[] = []; + const mergedFromBounds: Array<{ x: number; right: number }> = []; + const mergedFromCharStarts: number[] = []; + let lineText = ""; + for (let wi = 0; wi < lineWords.length; wi++) { + const w = lineWords[wi]; + // Separate words on a line with a single space when neither side + // already carries one (per-glyph runs often embed trailing spaces). + const wText = w.glyphs.map((g) => g.text).join(""); + if (wi > 0 && !/\s$/.test(lineText) && !/^\s/.test(wText)) { + lineText += " "; + } + for (const g of w.glyphs) { + mergedFromPtrs.push(g.ptr); + mergedFromTexts.push(g.text); + mergedFromBounds.push({ x: g.x, right: g.right }); + mergedFromCharStarts.push(lineText.length); + lineText += g.text; + leafPtrs.push(g.ptr); + leafContainers.push(g.container); + if (g.right > maxRight) maxRight = g.right; + } + } + slots.push({ + startChar: cursorChar, + endChar: cursorChar + lineText.length, + baselineY: baseline, + matrixE: startX, + containerPtr: lineWords[0]?.glyphs[0]?.container ?? run.containerPtr, + fontId: run.fontId, + fontSize: run.fontSize, + fontSubset: run.fontSubset, + mergedFromPtrs, + mergedFromTexts, + mergedFromBounds, + mergedFromCharStarts, + }); + lineTexts.push(lineText); + cursorChar += lineText.length + 1; // +1 for "\n" + memberPtrs.push(lineWords[0]?.glyphs[0]?.ptr ?? 0); + memberContainers.push( + lineWords[0]?.glyphs[0]?.container ?? run.containerPtr, + ); + memberFs.push(baseline); + } + + run.paragraphLineSlots = slots; + run.paragraphLineHeight = lineHeight; + run.paragraphMemberPtrs = memberPtrs; + run.paragraphMemberContainers = memberContainers; + run.paragraphMemberFs = memberFs; + run.paragraphLeafPtrs = leafPtrs; + run.paragraphLeafContainers = leafContainers; + run.paragraphSoftStarts = lineIsHardStart.map((hard) => !hard); + // ONE "\n" per visual line, wrap-created breaks included. A soft break used + // to join with " ", which left run.text holding fewer lines than the page had + // ink for: buildExactLines then failed at the seam (the engine trims a + // wrapped line's trailing space, so the pen jumps backwards and the span + // reads NaN), `exact` came back null, and the box kept its pre-edit line + // count while the stale painted blocks were never replaced. Which breaks the + // WRAP owns is recorded in paragraphSoftStarts instead. + const glyphDerived = lineTexts + .map((t, i) => (i === 0 ? t : "\n" + t)) + .join(""); + // PDFium collapses runs of intra-line spaces in the glyph stream. + const stripWs = (s: string): string => s.replace(/\s+/g, ""); + if ( + preReflowText.length > 0 && + stripWs(glyphDerived) === stripWs(preReflowText) + ) { + const preLines = resegmentByLines(lineTexts, preReflowText); + let cursor = 0; + for (let i = 0; i < slots.length; i++) { + const preLine = preLines[i] ?? ""; + slots[i].mergedFromCharStarts = slots[i].mergedFromCharStarts.map((cs) => + posAtNonWsIndex(preLine, nonWsLen(lineTexts[i].slice(0, cs))), + ); + slots[i].startChar = cursor; + slots[i].endChar = cursor + preLine.length; + cursor += preLine.length + (i < slots.length - 1 ? 1 : 0); + } + run.text = preLines.map((t, i) => (i === 0 ? t : "\n" + t)).join(""); + } else { + run.text = glyphDerived; + } + + const s0 = slots[0]; + if (s0) { + run.mergedFromPtrs = [...s0.mergedFromPtrs]; + run.mergedFromTexts = [...s0.mergedFromTexts]; + run.mergedFromBounds = s0.mergedFromBounds.map((b) => ({ ...b })); + run.mergedFromCharStarts = [...s0.mergedFromCharStarts]; + if (s0.mergedFromPtrs.length > 0) run.pdfiumObjPtr = s0.mergedFromPtrs[0]; + } + + run.matrix = { ...run.matrix, e: startX, f: topBaseline }; + run.bounds = { + x: startX, + y: topBaseline - (lines.length - 1) * lineHeight - fontSize * 0.25, + width: Math.max(0, maxRight - startX), + height: lines.length * lineHeight + fontSize * 0.25, + }; +} + +function readObjBounds( + m: WrappedPdfiumModule, + ptr: number, +): { x: number; right: number } | null { + const l = m.pdfium.wasmExports.malloc(4); + const b = m.pdfium.wasmExports.malloc(4); + const r = m.pdfium.wasmExports.malloc(4); + const t = m.pdfium.wasmExports.malloc(4); + try { + if (!m.FPDFPageObj_GetBounds(ptr, l, b, r, t)) return null; + return { + x: m.pdfium.getValue(l, "float"), + right: m.pdfium.getValue(r, "float"), + }; + } catch { + return null; + } finally { + m.pdfium.wasmExports.free(l); + m.pdfium.wasmExports.free(b); + m.pdfium.wasmExports.free(r); + m.pdfium.wasmExports.free(t); + } +} + +/** The text-matrix baseline (translation `f`) - consistent across a line. */ +function readObjBaseline(m: WrappedPdfiumModule, ptr: number): number { + const buf = m.pdfium.wasmExports.malloc(6 * 4); + try { + if (!m.FPDFPageObj_GetMatrix(ptr, buf)) return 0; + return m.pdfium.getValue(buf + 20, "float"); + } catch { + return 0; + } finally { + m.pdfium.wasmExports.free(buf); + } +} + +function readObjText( + m: WrappedPdfiumModule, + textPage: number, + ptr: number, +): string { + try { + const len = m.FPDFTextObj_GetText(ptr, textPage, 0, 0); + if (len <= 2) return ""; + const buf = m.pdfium.wasmExports.malloc(len); + try { + m.FPDFTextObj_GetText(ptr, textPage, buf, len); + return readUtf16(m, buf, len); + } finally { + m.pdfium.wasmExports.free(buf); + } + } catch { + return ""; + } +} + +function snapshotRun(run: TextRun): RunSnapshot { + return { + text: run.text, + matrixE: run.matrix.e, + matrixF: run.matrix.f, + bounds: { ...run.bounds }, + paragraphLineHeight: run.paragraphLineHeight, + paragraphMemberPtrs: [...run.paragraphMemberPtrs], + paragraphMemberContainers: [...run.paragraphMemberContainers], + paragraphMemberFs: [...run.paragraphMemberFs], + paragraphLeafPtrs: [...run.paragraphLeafPtrs], + paragraphLeafContainers: [...run.paragraphLeafContainers], + paragraphLineSlots: run.paragraphLineSlots.map(cloneSlot), + paragraphSoftStarts: [...run.paragraphSoftStarts], + mergedFromPtrs: [...run.mergedFromPtrs], + mergedFromTexts: [...run.mergedFromTexts], + mergedFromBounds: run.mergedFromBounds.map((b) => ({ ...b })), + mergedFromCharStarts: [...run.mergedFromCharStarts], + pdfiumObjPtr: run.pdfiumObjPtr, + }; +} + +function restoreRun(run: TextRun, prev: RunSnapshot): void { + run.text = prev.text; + run.matrix = { ...run.matrix, e: prev.matrixE, f: prev.matrixF }; + run.bounds = { ...prev.bounds }; + run.paragraphLineHeight = prev.paragraphLineHeight; + run.paragraphMemberPtrs = [...prev.paragraphMemberPtrs]; + run.paragraphMemberContainers = [...prev.paragraphMemberContainers]; + run.paragraphMemberFs = [...prev.paragraphMemberFs]; + run.paragraphLeafPtrs = [...prev.paragraphLeafPtrs]; + run.paragraphLeafContainers = [...prev.paragraphLeafContainers]; + run.paragraphLineSlots = prev.paragraphLineSlots.map(cloneSlot); + run.paragraphSoftStarts = [...prev.paragraphSoftStarts]; + run.mergedFromPtrs = [...prev.mergedFromPtrs]; + run.mergedFromTexts = [...prev.mergedFromTexts]; + run.mergedFromBounds = prev.mergedFromBounds.map((b) => ({ ...b })); + run.mergedFromCharStarts = [...prev.mergedFromCharStarts]; + run.pdfiumObjPtr = prev.pdfiumObjPtr; +} + +function cloneSlot(s: ParagraphLineSlot): ParagraphLineSlot { + return { + startChar: s.startChar, + endChar: s.endChar, + baselineY: s.baselineY, + matrixE: s.matrixE, + containerPtr: s.containerPtr, + fontId: s.fontId, + fontSize: s.fontSize, + fontSubset: s.fontSubset, + mergedFromPtrs: [...s.mergedFromPtrs], + mergedFromTexts: [...s.mergedFromTexts], + mergedFromBounds: s.mergedFromBounds.map((b) => ({ ...b })), + mergedFromCharStarts: [...s.mergedFromCharStarts], + }; +} + +/** Count of non-whitespace characters in a string. */ +function nonWsLen(s: string): number { + return s.replace(/\s+/g, "").length; +} + +// Position in `text` of the `idx`-th (0-based) non-whitespace char; +// `text.length` when `idx` is past the end. +function posAtNonWsIndex(text: string, idx: number): number { + let n = 0; + for (let i = 0; i < text.length; i++) { + if (!/\s/.test(text[i])) { + if (n === idx) return i; + n++; + } + } + return text.length; +} + +// Re-segment `preReflowText` into per-visual-line texts that share the same +// non-whitespace content as `lineTexts`. +function resegmentByLines( + lineTexts: string[], + preReflowText: string, +): string[] { + const out: string[] = []; + let cumNw = 0; + for (const lt of lineTexts) { + const nw = nonWsLen(lt); + if (nw === 0) { + out.push(""); + continue; + } + const start = posAtNonWsIndex(preReflowText, cumNw); + const lastPos = posAtNonWsIndex(preReflowText, cumNw + nw - 1); + out.push(preReflowText.slice(start, lastPos + 1)); + cumNw += nw; + } + return out; +} diff --git a/frontend/editor/src/core/tools/pdfTextEditor/commands/ReplaceImageCommand.ts b/frontend/editor/src/core/tools/pdfTextEditor/commands/ReplaceImageCommand.ts new file mode 100644 index 0000000000..be778e6870 --- /dev/null +++ b/frontend/editor/src/core/tools/pdfTextEditor/commands/ReplaceImageCommand.ts @@ -0,0 +1,265 @@ +import type { Command } from "@app/tools/pdfTextEditor/commands/Command"; +import type { EditorDocument } from "@app/tools/pdfTextEditor/model/EditorDocument"; +import type { ImageObject } from "@app/tools/pdfTextEditor/model/ImageObject"; +import type { Page } from "@app/tools/pdfTextEditor/model/Page"; +import type { Affine, PageRect } from "@app/tools/pdfTextEditor/types"; +import type { WrappedPdfiumModule } from "@embedpdf/pdfium"; +import type { DecodedImage } from "@app/utils/pdfiumBitmapUtils"; +import { + embedBitmapImageOnPage, + embedJpegImageOnPage, +} from "@app/utils/pdfiumBitmapUtils"; + +interface ZOrderModule { + FPDFPage_InsertObjectAtIndex?: ( + page: number, + obj: number, + index: number, + ) => boolean; +} + +interface MatrixModule { + FPDFImageObj_SetMatrix?: ( + obj: number, + a: number, + b: number, + c: number, + d: number, + e: number, + f: number, + ) => boolean; + FPDFPageObj_SetMatrix?: (obj: number, matrix: number) => boolean; + pdfium?: { + setValue?: (ptr: number, value: number, type: string) => void; + wasmExports?: { + malloc?: (size: number) => number; + free?: (ptr: number) => void; + }; + }; +} + +/** Swap an image's pixels but keep its matrix, so it fills the same box. */ +interface ActivityModule { + FPDFPageObj_SetIsActive?: (obj: number, active: boolean) => boolean; +} + +// Hiding beats detaching for an object the page does not own: it is a pure +// state flip, so undo is exact and nothing changes hands. +function setActive( + m: EditorDocument["module"], + ptr: number, + active: boolean, +): void { + if (!ptr) return; + try { + (m as unknown as ActivityModule).FPDFPageObj_SetIsActive?.(ptr, active); + } catch { + /* best-effort */ + } +} + +export class ReplaceImageCommand implements Command { + readonly type = "replace-image"; + private readonly pageIndex: number; + private readonly imageId: string; + private readonly image: DecodedImage; + private readonly jpegBytes?: Uint8Array; + private prevObjPtr: number; + private prevMatrix: Affine | null; + private prevBounds: PageRect | null; + private prevIndex: number; + private nextObjPtr: number; + + constructor(opts: { + pageIndex: number; + imageId: string; + image: DecodedImage; + jpegBytes?: Uint8Array; + }) { + this.pageIndex = opts.pageIndex; + this.imageId = opts.imageId; + this.image = opts.image; + this.jpegBytes = opts.jpegBytes; + this.prevObjPtr = 0; + this.prevMatrix = null; + this.prevBounds = null; + this.prevIndex = -1; + this.nextObjPtr = 0; + } + + apply(doc: EditorDocument): void { + const page = doc.page(this.pageIndex); + const img = page.findImage(this.imageId); + if (!img || !img.pdfiumObjPtr) return; + const m = doc.module; + // A form-nested original cannot be detached and put back (this build has + // no FPDFFormObj_InsertObject), so hide it in place instead and draw the + // replacement at page level using its already-composed page-space matrix. + const nested = img.containerPtr !== 0; + if (this.prevMatrix === null || this.prevBounds === null) { + this.prevObjPtr = img.pdfiumObjPtr; + this.prevMatrix = { ...img.matrix }; + this.prevBounds = { ...img.bounds }; + this.prevIndex = objectIndex(m, page.pagePtr, this.prevObjPtr); + } + const matrix = this.prevMatrix; + const box = this.prevBounds; + // Redo: revert only detached the replacement, so re-attach that same + // object rather than embedding the pixels a second time. + if (this.nextObjPtr) { + if (nested) setActive(m, this.prevObjPtr, false); + else m.FPDFPage_RemoveObject(page.pagePtr, this.prevObjPtr); + insertObjectAt(m, page.pagePtr, this.nextObjPtr, this.prevIndex); + this.adopt(page, img, this.nextObjPtr); + return; + } + let objPtr = this.jpegBytes + ? embedJpegImageOnPage( + m, + doc.docPtr, + page.pagePtr, + this.jpegBytes, + box.x, + box.y, + box.width, + box.height, + ) + : 0; + if (!objPtr) { + objPtr = embedBitmapImageOnPage( + m, + doc.docPtr, + page.pagePtr, + this.image, + box.x, + box.y, + box.width, + box.height, + ); + } + // Embedding failed - leave the page exactly as it was. + if (!objPtr) return; + // The embed helpers write an axis-aligned (w,0,0,h,x,y) box, which flips + // the image on a rotated page; the captured matrix is the truth here. + setImageMatrix(m, objPtr, matrix); + // Detach only: the old object carries the original pixels for undo, so + // destroying it would leave this command's undo entry pointing at free memory. + if (nested) setActive(m, this.prevObjPtr, false); + else m.FPDFPage_RemoveObject(page.pagePtr, this.prevObjPtr); + // The embed appended, so without this the replacement jumps to the top. + if (this.prevIndex >= 0 && supportsInsertAtIndex(m)) { + m.FPDFPage_RemoveObject(page.pagePtr, objPtr); + insertObjectAt(m, page.pagePtr, objPtr, this.prevIndex); + } + this.nextObjPtr = objPtr; + this.adopt(page, img, objPtr); + } + + revert(doc: EditorDocument): void { + if (!this.nextObjPtr || !this.prevObjPtr) return; + const page = doc.page(this.pageIndex); + const img = page.findImage(this.imageId); + if (!img) return; + const m = doc.module; + // Detach only again: the replacement is what redo re-attaches. + m.FPDFPage_RemoveObject(page.pagePtr, this.nextObjPtr); + if (img.containerPtr) setActive(m, this.prevObjPtr, true); + else insertObjectAt(m, page.pagePtr, this.prevObjPtr, this.prevIndex); + this.adopt(page, img, this.prevObjPtr); + } + + private adopt(page: Page, img: ImageObject, objPtr: number): void { + img.pdfiumObjPtr = objPtr; + if (this.prevMatrix) img.matrix = { ...this.prevMatrix }; + if (this.prevBounds) img.bounds = { ...this.prevBounds }; + img.dirty = true; + page.markDirty(); + page.markNeedsGenerate(); + } +} + +function objectIndex( + m: WrappedPdfiumModule, + pagePtr: number, + objPtr: number, +): number { + const total = m.FPDFPage_CountObjects(pagePtr); + for (let i = 0; i < total; i++) { + if (m.FPDFPage_GetObject(pagePtr, i) === objPtr) return i; + } + return -1; +} + +function supportsInsertAtIndex(m: WrappedPdfiumModule): boolean { + return ( + typeof (m as unknown as ZOrderModule).FPDFPage_InsertObjectAtIndex === + "function" + ); +} + +/** Re-attach a detached object at `index`, appending when that is unavailable. */ +function insertObjectAt( + m: WrappedPdfiumModule, + pagePtr: number, + objPtr: number, + index: number, +): void { + const insertAt = (m as unknown as ZOrderModule).FPDFPage_InsertObjectAtIndex; + if (typeof insertAt === "function" && index >= 0) { + try { + if (insertAt.call(m, pagePtr, objPtr, index)) return; + } catch { + /* fall through to append */ + } + } + m.FPDFPage_InsertObject(pagePtr, objPtr); +} + +function setImageMatrix( + m: WrappedPdfiumModule, + objPtr: number, + matrix: Affine, +): void { + const mod = m as unknown as MatrixModule; + const direct = mod.FPDFImageObj_SetMatrix; + if (typeof direct === "function") { + try { + const ok = direct.call( + m, + objPtr, + matrix.a, + matrix.b, + matrix.c, + matrix.d, + matrix.e, + matrix.f, + ); + if (ok) return; + } catch { + /* fall through to the struct setter */ + } + } + writeMatrixStruct(mod, objPtr, matrix); +} + +/** FS_MATRIX fallback for builds without the scalar `FPDFImageObj_SetMatrix`. */ +function writeMatrixStruct( + mod: MatrixModule, + objPtr: number, + matrix: Affine, +): void { + const setter = mod.FPDFPageObj_SetMatrix; + const rt = mod.pdfium; + if (!setter || !rt?.setValue || !rt.wasmExports?.malloc) return; + const ptr = rt.wasmExports.malloc(6 * 4); + if (!ptr) return; + const values = [matrix.a, matrix.b, matrix.c, matrix.d, matrix.e, matrix.f]; + try { + values.forEach((v, i) => rt.setValue?.(ptr + i * 4, v, "float")); + setter(objPtr, ptr); + } catch { + /* best-effort */ + } finally { + rt.wasmExports.free?.(ptr); + } +} diff --git a/frontend/editor/src/core/tools/pdfTextEditor/commands/SetColourCommand.ts b/frontend/editor/src/core/tools/pdfTextEditor/commands/SetColourCommand.ts new file mode 100644 index 0000000000..8d1e3309d4 --- /dev/null +++ b/frontend/editor/src/core/tools/pdfTextEditor/commands/SetColourCommand.ts @@ -0,0 +1,117 @@ +import type { Command } from "@app/tools/pdfTextEditor/commands/Command"; +import type { EditorDocument } from "@app/tools/pdfTextEditor/model/EditorDocument"; +import type { RGBA } from "@app/tools/pdfTextEditor/types"; +import { PdfiumTextWriter } from "@app/tools/pdfTextEditor/pdfium/PdfiumTextWriter"; +import { collectMemberPtrs } from "@app/tools/pdfTextEditor/commands/editTextHelpers"; + +export class SetColourCommand implements Command { + readonly type = "set-colour"; + private readonly pageIndex: number; + private readonly runId: string; + private readonly nextFill: RGBA; + private prevFill: RGBA | null; + /** Each member object's OWN pre-apply fill. */ + private prevMemberFills: Array<{ ptr: number; fill: RGBA }> | null; + + constructor(opts: { pageIndex: number; runId: string; nextFill: RGBA }) { + this.pageIndex = opts.pageIndex; + this.runId = opts.runId; + this.nextFill = opts.nextFill; + this.prevFill = null; + this.prevMemberFills = null; + } + + apply(doc: EditorDocument): void { + const page = doc.page(this.pageIndex); + const run = page.findRun(this.runId); + if (!run) return; + if (this.prevFill === null) { + this.prevFill = { ...run.fill }; + const m = doc.module; + const seen = new Set(); + this.prevMemberFills = []; + for (const ptr of collectMemberPtrs(run)) { + if (!ptr || seen.has(ptr)) continue; + seen.add(ptr); + this.prevMemberFills.push({ + ptr, + fill: readObjFill(m, ptr) ?? { ...run.fill }, + }); + } + } + run.fill = { ...this.nextFill }; + run.dirty = true; + page.markDirty(); + PdfiumTextWriter.commitRunFill(doc, page, run); + } + + revert(doc: EditorDocument): void { + if (this.prevFill === null) return; + const page = doc.page(this.pageIndex); + const run = page.findRun(this.runId); + if (!run) return; + run.fill = { ...this.prevFill }; + run.dirty = true; + page.markDirty(); + // Restore each member's own colour rather than stamping the rep fill + // over the whole group. + const m = doc.module; + let restoredAny = false; + for (const entry of this.prevMemberFills ?? []) { + try { + m.FPDFPageObj_SetFillColor( + entry.ptr, + entry.fill.r, + entry.fill.g, + entry.fill.b, + entry.fill.a, + ); + restoredAny = true; + } catch { + /* best-effort - stale ptrs silently skipped */ + } + } + if (restoredAny) page.markNeedsGenerate(); + else PdfiumTextWriter.commitRunFill(doc, page, run); + } + + /** One colour-picker DRAG fires dozens of commands. */ + coalesceKey(): string { + return "set-colour"; + } + + describe(): string { + return `Set colour on ${this.runId}`; + } +} + +/** Read an object's current fill colour (0-255 RGBA), or null on failure. */ +function readObjFill( + m: import("@embedpdf/pdfium").WrappedPdfiumModule, + objPtr: number, +): RGBA | null { + const exports = m.pdfium.wasmExports as unknown as { + malloc: (n: number) => number; + free: (p: number) => void; + }; + const r = exports.malloc(4); + const g = exports.malloc(4); + const b = exports.malloc(4); + const a = exports.malloc(4); + try { + if (!m.FPDFPageObj_GetFillColor(objPtr, r, g, b, a)) return null; + return { + r: m.pdfium.getValue(r, "i32"), + g: m.pdfium.getValue(g, "i32"), + b: m.pdfium.getValue(b, "i32"), + a: m.pdfium.getValue(a, "i32"), + }; + } catch { + return null; + } finally { + exports.free(r); + exports.free(g); + exports.free(b); + exports.free(a); + } +} diff --git a/frontend/editor/src/core/tools/pdfTextEditor/commands/SetFontFamilyCommand.ts b/frontend/editor/src/core/tools/pdfTextEditor/commands/SetFontFamilyCommand.ts new file mode 100644 index 0000000000..5d5e05f286 --- /dev/null +++ b/frontend/editor/src/core/tools/pdfTextEditor/commands/SetFontFamilyCommand.ts @@ -0,0 +1,251 @@ +import type { Command } from "@app/tools/pdfTextEditor/commands/Command"; +import type { EditorDocument } from "@app/tools/pdfTextEditor/model/EditorDocument"; +import { + cloneParagraphLineSlot, + type ParagraphLineSlot, + type TextRun, +} from "@app/tools/pdfTextEditor/model/TextRun"; +import { + collectContainersByPtr, + collectMemberPtrs, + emitRunLines, + planLineOrigins, + removeMemberPtrs, +} from "@app/tools/pdfTextEditor/commands/editTextHelpers"; +import { deviceFontEmitCount } from "@app/tools/pdfTextEditor/util/deviceFontEmbed"; + +// Re-emit a run's text in another family: PDFium has no SetFont accessor. +// Device fonts embed when pre-warmed, else the nearest standard face. +export class SetFontFamilyCommand implements Command { + readonly type = "set-font-family"; + private readonly pageIndex: number; + private readonly runId: string; + private readonly nextFamily: string; + /** Full pre-edit model snapshot for revert. */ + private prev: RunModelSnapshot | null; + /** Original on-page member ptrs (re-inserted on revert). */ + private prevMemberPtrs: number[]; + /** Every object this command created (removed on revert). */ + private createdPtrs: number[]; + + constructor(opts: { pageIndex: number; runId: string; nextFamily: string }) { + this.pageIndex = opts.pageIndex; + this.runId = opts.runId; + this.nextFamily = opts.nextFamily; + this.prev = null; + this.prevMemberPtrs = []; + this.createdPtrs = []; + } + + apply(doc: EditorDocument): void { + const page = doc.page(this.pageIndex); + const run = page.findRun(this.runId); + if (!run) return; + const m = doc.module; + + if (this.prev === null) { + this.prev = snapshotRun(run); + this.prevMemberPtrs = collectMemberPtrs(run).slice(); + } + + // Detach every original object so the page stops painting them. + removeMemberPtrs( + m, + page, + this.prevMemberPtrs, + collectContainersByPtr(run), + run.containerPtr, + ); + + // Re-emit one base-14 object per visual line at descending baselines. + const lineHeight = + run.paragraphLineHeight > 0 + ? run.paragraphLineHeight + : run.fontSize * 1.2; + // Prefer per-line SLOT ranges: run.text joins SOFT-wrapped lines with + // separators a \n split can't see. Baseline stepping for the fallback case + // comes from planLineOrigins so it cannot drift from EditTextCommand's. + const slots = run.paragraphLineSlots; + const splitTexts = slots.length > 0 ? null : run.text.split(/\r?\n/); + const emitLines: Array<{ text: string; x: number; y: number }> = splitTexts + ? (() => { + const origins = planLineOrigins(run, splitTexts.length, lineHeight); + return splitTexts.map((text, i) => ({ text, ...origins[i] })); + })() + : slots.map((s) => ({ + text: run.text + .slice( + Math.max(0, s.startChar), + Math.min(run.text.length, s.endChar), + ) + .replace(/[\r\n]+$/, ""), + x: s.matrixE, + y: s.baselineY, + })); + const lineAnchors: number[] = []; + const memberFs: number[] = []; + const leaf: number[] = []; + const created: number[] = []; + // Emits with the embedded device face are counted, so the font id below + // can say what actually rendered rather than what was requested. + const deviceEmitsBefore = deviceFontEmitCount(doc, this.nextFamily); + const emitted = emitRunLines({ + doc, + page, + run, + lines: emitLines.map((l) => l.text), + origins: emitLines.map((l) => ({ x: l.x, y: l.y })), + originalFontPtr: 0, // base-14: never reuse the source font + fallbackFamily: this.nextFamily, + }); + for (const line of emitted) { + memberFs.push(line.y); + if (line.ptrs.length === 0) { + lineAnchors.push(0); + continue; + } + lineAnchors.push(line.ptrs[0]); + leaf.push(...line.ptrs); + created.push(...line.ptrs); + } + + if (created.length === 0) { + // Nothing emitted (e.g. all-whitespace dropped) - restore and bail. + this.reinsertOriginals(m, page); + restoreRun(run, this.prev); + // Neutralise the command: it still lands in history, and a revert with + // `prev` set would reinsert the originals a SECOND time. + this.prev = null; + return; + } + + this.createdPtrs = created; + run.pdfiumObjPtr = lineAnchors.find((p) => p) ?? leaf[0]; + // `device:` marks glyphs from an embedded device font; a substituted run + // keeps `base14:`, so nothing keying off that prefix changes meaning. + const embedded = + deviceFontEmitCount(doc, this.nextFamily) > deviceEmitsBefore; + run.fontId = `${embedded ? "device" : "base14"}:${this.nextFamily}`; + run.fontSubset = false; + // Reset ALL model bookkeeping to the freshly-emitted objects so later + // commands act on the live objects, not the removed originals. + run.mergedFromPtrs = []; + run.mergedFromTexts = []; + run.mergedFromBounds = []; + run.mergedFromCharStarts = []; + run.paragraphLineSlots = []; + // Track every per-word leaf so later recolour/resize/move hit all words, + // not just the anchor. Line height stays paragraph-only (>1 line). + run.paragraphMemberPtrs = lineAnchors; + run.paragraphMemberContainers = lineAnchors.map(() => 0); + run.paragraphMemberFs = memberFs; + run.paragraphLeafPtrs = leaf; + run.paragraphLeafContainers = leaf.map(() => 0); + if (emitLines.length > 1) { + run.paragraphLineHeight = lineHeight; + } + run.containerPtr = 0; + run.dirty = true; + page.markDirty(); + page.markNeedsGenerate(); + } + + revert(doc: EditorDocument): void { + if (!this.prev) return; + const page = doc.page(this.pageIndex); + const run = page.findRun(this.runId); + if (!run) return; + const m = doc.module; + + for (const ptr of this.createdPtrs) { + if (!ptr) continue; + try { + m.FPDFPage_RemoveObject(page.pagePtr, ptr); + } catch { + /* best-effort */ + } + } + this.createdPtrs = []; + this.reinsertOriginals(m, page); + restoreRun(run, this.prev); + run.dirty = true; + page.markDirty(); + page.markNeedsGenerate(); + } + + private reinsertOriginals( + m: import("@embedpdf/pdfium").WrappedPdfiumModule, + page: import("@app/tools/pdfTextEditor/model/Page").Page, + ): void { + for (const ptr of this.prevMemberPtrs) { + if (!ptr) continue; + try { + m.FPDFPage_InsertObject(page.pagePtr, ptr); + } catch { + /* best-effort */ + } + } + } +} + +interface RunModelSnapshot { + text: string; + fontId: string; + fontSubset: boolean; + fill: { r: number; g: number; b: number; a: number }; + pdfiumObjPtr: number; + containerPtr: number; + mergedFromPtrs: number[]; + mergedFromTexts: string[]; + mergedFromBounds: Array<{ x: number; right: number }>; + mergedFromCharStarts: number[]; + paragraphMemberPtrs: number[]; + paragraphMemberContainers: number[]; + paragraphMemberFs: number[]; + paragraphLeafPtrs: number[]; + paragraphLeafContainers: number[]; + paragraphLineSlots: ParagraphLineSlot[]; + paragraphLineHeight: number; +} + +function snapshotRun(run: TextRun): RunModelSnapshot { + return { + text: run.text, + fontId: run.fontId, + fontSubset: run.fontSubset, + fill: { ...run.fill }, + pdfiumObjPtr: run.pdfiumObjPtr, + containerPtr: run.containerPtr, + mergedFromPtrs: [...run.mergedFromPtrs], + mergedFromTexts: [...run.mergedFromTexts], + mergedFromBounds: run.mergedFromBounds.map((b) => ({ ...b })), + mergedFromCharStarts: [...run.mergedFromCharStarts], + paragraphMemberPtrs: [...run.paragraphMemberPtrs], + paragraphMemberContainers: [...run.paragraphMemberContainers], + paragraphMemberFs: [...run.paragraphMemberFs], + paragraphLeafPtrs: [...run.paragraphLeafPtrs], + paragraphLeafContainers: [...run.paragraphLeafContainers], + paragraphLineSlots: run.paragraphLineSlots.map(cloneParagraphLineSlot), + paragraphLineHeight: run.paragraphLineHeight, + }; +} + +function restoreRun(run: TextRun, snap: RunModelSnapshot): void { + run.text = snap.text; + run.fontId = snap.fontId; + run.fontSubset = snap.fontSubset; + run.fill = { ...snap.fill }; + run.pdfiumObjPtr = snap.pdfiumObjPtr; + run.containerPtr = snap.containerPtr; + run.mergedFromPtrs = [...snap.mergedFromPtrs]; + run.mergedFromTexts = [...snap.mergedFromTexts]; + run.mergedFromBounds = snap.mergedFromBounds.map((b) => ({ ...b })); + run.mergedFromCharStarts = [...snap.mergedFromCharStarts]; + run.paragraphMemberPtrs = [...snap.paragraphMemberPtrs]; + run.paragraphMemberContainers = [...snap.paragraphMemberContainers]; + run.paragraphMemberFs = [...snap.paragraphMemberFs]; + run.paragraphLeafPtrs = [...snap.paragraphLeafPtrs]; + run.paragraphLeafContainers = [...snap.paragraphLeafContainers]; + run.paragraphLineSlots = snap.paragraphLineSlots.map(cloneParagraphLineSlot); + run.paragraphLineHeight = snap.paragraphLineHeight; +} diff --git a/frontend/editor/src/core/tools/pdfTextEditor/commands/SetFontSizeCommand.ts b/frontend/editor/src/core/tools/pdfTextEditor/commands/SetFontSizeCommand.ts new file mode 100644 index 0000000000..dc53a25ee7 --- /dev/null +++ b/frontend/editor/src/core/tools/pdfTextEditor/commands/SetFontSizeCommand.ts @@ -0,0 +1,163 @@ +import type { Command } from "@app/tools/pdfTextEditor/commands/Command"; +import type { EditorDocument } from "@app/tools/pdfTextEditor/model/EditorDocument"; +import { collectMemberPtrs } from "@app/tools/pdfTextEditor/commands/editTextHelpers"; +import { transformObject } from "@app/tools/pdfTextEditor/util/objectTransform"; + +/** Scale a text run so its effective on-page size matches `nextSize`. */ +export class SetFontSizeCommand implements Command { + readonly type = "set-font-size"; + private readonly pageIndex: number; + private readonly runId: string; + private readonly nextSize: number; + private prevSize: number | null; + + constructor(opts: { pageIndex: number; runId: string; nextSize: number }) { + this.pageIndex = opts.pageIndex; + this.runId = opts.runId; + this.nextSize = opts.nextSize; + this.prevSize = null; + } + + apply(doc: EditorDocument): void { + const page = doc.page(this.pageIndex); + const run = page.findRun(this.runId); + if (!run || !run.pdfiumObjPtr) return; + if (this.prevSize === null) { + this.prevSize = run.fontSize; + } + const ratio = this.nextSize / Math.max(0.01, run.fontSize); + // Scale about the run's own baseline anchor, NOT the page origin - scaling + // about moves the glyphs diagonally and the move persists on save. + this.scaleAllPtrs( + doc, + collectMemberPtrs(run), + ratio, + run.matrix.e, + run.matrix.f, + ); + run.fontSize = this.nextSize; + run.matrix = scaleMatrix( + run.matrix, + this.nextSize / Math.max(0.01, this.prevSize), + ); + rescaleRunModel(run, ratio, run.matrix.e, run.matrix.f); + // The glyph gaps scale with the glyphs, so the tracked letter-spacing + // must scale too or a later edit re-emits with the stale pt value. + run.charSpacingPt *= ratio; + run.dirty = true; + page.markDirty(); + page.markNeedsGenerate(); + } + + revert(doc: EditorDocument): void { + if (this.prevSize === null) return; + const page = doc.page(this.pageIndex); + const run = page.findRun(this.runId); + if (!run || !run.pdfiumObjPtr) return; + const ratio = this.prevSize / Math.max(0.01, run.fontSize); + this.scaleAllPtrs( + doc, + collectMemberPtrs(run), + ratio, + run.matrix.e, + run.matrix.f, + ); + run.fontSize = this.prevSize; + run.matrix = scaleMatrix(run.matrix, ratio); + rescaleRunModel(run, ratio, run.matrix.e, run.matrix.f); + run.charSpacingPt *= ratio; + run.dirty = true; + page.markDirty(); + page.markNeedsGenerate(); + } + + private scaleAllPtrs( + doc: EditorDocument, + ptrs: number[], + relativeScale: number, + anchorX: number, + anchorY: number, + ): void { + if (!Number.isFinite(relativeScale) || relativeScale === 1) return; + const m = doc.module; + // Scale about (anchorX, anchorY): translate(-a) · scale(s) · translate(+a) + // collapses to [s,0,0,s, ax*(1-s), ay*(1-s)] - a single Transform call. + const tx = anchorX * (1 - relativeScale); + const ty = anchorY * (1 - relativeScale); + const seen = new Set(); + for (const ptr of ptrs) { + if (!ptr || seen.has(ptr)) continue; + seen.add(ptr); + try { + transformObject( + m, + + ptr, + relativeScale, + 0, + 0, + relativeScale, + tx, + ty, + ); + } catch { + /* best-effort - missing ptr is silently skipped */ + } + } + } + + /** The stepper fires per tick; coalesce so one adjustment is one undo step. */ + coalesceKey(): string { + return `set-font-size:${this.pageIndex}:${this.runId}`; + } +} + +/** Mirror the PDFium object scaling in the run's model bookkeeping. */ +function rescaleRunModel( + run: import("@app/tools/pdfTextEditor/model/TextRun").TextRun, + s: number, + ax: number, + ay: number, +): void { + if (!Number.isFinite(s) || s === 1) return; + const mapX = (x: number) => s * x + (1 - s) * ax; + const mapY = (y: number) => s * y + (1 - s) * ay; + run.bounds = { + x: mapX(run.bounds.x), + y: mapY(run.bounds.y), + width: run.bounds.width * s, + height: run.bounds.height * s, + }; + run.mergedFromBounds = run.mergedFromBounds.map((b) => ({ + x: mapX(b.x), + right: mapX(b.right), + })); + run.paragraphMemberFs = run.paragraphMemberFs.map(mapY); + if (run.paragraphLineHeight > 0) run.paragraphLineHeight *= s; + for (const slot of run.paragraphLineSlots) { + slot.baselineY = mapY(slot.baselineY); + slot.matrixE = mapX(slot.matrixE); + slot.fontSize *= s; + slot.mergedFromBounds = slot.mergedFromBounds.map((b) => ({ + x: mapX(b.x), + right: mapX(b.right), + })); + } +} + +function scaleMatrix( + m: { a: number; b: number; c: number; d: number; e: number; f: number }, + ratio: number, +) { + if (!Number.isFinite(ratio) || ratio === 1) return m; + // Only the scale part changes; the anchor (e,f) stays put so the run keeps + // its on-page position (matches the anchored object Transform above). + return { + a: m.a * ratio, + b: m.b * ratio, + c: m.c * ratio, + d: m.d * ratio, + e: m.e, + f: m.f, + }; +} diff --git a/frontend/editor/src/core/tools/pdfTextEditor/commands/SetImageTransformCommand.ts b/frontend/editor/src/core/tools/pdfTextEditor/commands/SetImageTransformCommand.ts new file mode 100644 index 0000000000..db1af1a3b0 --- /dev/null +++ b/frontend/editor/src/core/tools/pdfTextEditor/commands/SetImageTransformCommand.ts @@ -0,0 +1,124 @@ +import type { Command } from "@app/tools/pdfTextEditor/commands/Command"; +import type { EditorDocument } from "@app/tools/pdfTextEditor/model/EditorDocument"; +import type { Affine, PageRect } from "@app/tools/pdfTextEditor/types"; +import { + imageMatrixBounds, + remapImageMatrix, +} from "@app/tools/pdfTextEditor/model/affine"; +import { retargetClipPath } from "@app/tools/pdfTextEditor/util/objectTransform"; + +/** Set an image object's transform to an absolute target. */ +export class SetImageTransformCommand implements Command { + readonly type = "set-image-transform"; + private readonly pageIndex: number; + private readonly imageId: string; + private readonly nextBounds: PageRect; + private prevBounds: PageRect | null; + private prevMatrix: Affine | null; + + constructor(opts: { + pageIndex: number; + imageId: string; + nextBounds: PageRect; + }) { + this.pageIndex = opts.pageIndex; + this.imageId = opts.imageId; + this.nextBounds = opts.nextBounds; + this.prevBounds = null; + this.prevMatrix = null; + } + + apply(doc: EditorDocument): void { + const page = doc.page(this.pageIndex); + const img = page.findImage(this.imageId); + if (!img || !img.pdfiumObjPtr) return; + let prevBounds = this.prevBounds; + let prevMatrix = this.prevMatrix; + if (prevBounds === null || prevMatrix === null) { + prevBounds = { ...img.bounds }; + prevMatrix = { ...img.matrix }; + this.prevBounds = prevBounds; + this.prevMatrix = prevMatrix; + } + // Remap the image's display AABB from prevBounds -> nextBounds while + // keeping the orientation/aspect of prevMatrix. + const next = remapImageMatrix( + prevMatrix, + prevBounds, + this.nextBounds, + page.display, + ); + setMatrix(doc, img.pdfiumObjPtr, next); + retargetClipPath(doc.module, img.pdfiumObjPtr, prevMatrix, next); + img.matrix = next; + img.bounds = imageMatrixBounds(next); + img.dirty = true; + page.markDirty(); + page.markNeedsGenerate(); + } + + revert(doc: EditorDocument): void { + if (!this.prevBounds || !this.prevMatrix) return; + const page = doc.page(this.pageIndex); + const img = page.findImage(this.imageId); + if (!img || !img.pdfiumObjPtr) return; + // Restore the captured matrix exactly (preserves any rotation / + // shear that wasn't expressed in the simple bounds form). + const m = doc.module; + const fn = ( + m as unknown as { + FPDFImageObj_SetMatrix?: ( + obj: number, + a: number, + b: number, + c: number, + d: number, + e: number, + f: number, + ) => boolean; + } + ).FPDFImageObj_SetMatrix; + if (!fn) return; + try { + fn( + img.pdfiumObjPtr, + this.prevMatrix.a, + this.prevMatrix.b, + this.prevMatrix.c, + this.prevMatrix.d, + this.prevMatrix.e, + this.prevMatrix.f, + ); + } catch { + /* best-effort */ + } + retargetClipPath(m, img.pdfiumObjPtr, img.matrix, this.prevMatrix); + img.bounds = { ...this.prevBounds }; + img.matrix = { ...this.prevMatrix }; + img.dirty = true; + page.markDirty(); + page.markNeedsGenerate(); + } +} + +function setMatrix(doc: EditorDocument, objPtr: number, m: Affine): void { + const fn = ( + doc.module as unknown as { + FPDFImageObj_SetMatrix?: ( + obj: number, + a: number, + b: number, + c: number, + d: number, + e: number, + f: number, + ) => boolean; + } + ).FPDFImageObj_SetMatrix; + if (!fn) return; + try { + fn(objPtr, m.a, m.b, m.c, m.d, m.e, m.f); + } catch { + /* best-effort */ + } +} diff --git a/frontend/editor/src/core/tools/pdfTextEditor/commands/SetLockCommand.ts b/frontend/editor/src/core/tools/pdfTextEditor/commands/SetLockCommand.ts new file mode 100644 index 0000000000..07f48cbd59 --- /dev/null +++ b/frontend/editor/src/core/tools/pdfTextEditor/commands/SetLockCommand.ts @@ -0,0 +1,62 @@ +import type { Command } from "@app/tools/pdfTextEditor/commands/Command"; +import type { EditorDocument } from "@app/tools/pdfTextEditor/model/EditorDocument"; + +/** Toggle the session-only `locked` flag on a text run or image object. */ +export class SetLockCommand implements Command { + readonly type = "set-lock"; + private readonly pageIndex: number; + private readonly runId: string | null; + private readonly imageId: string | null; + private readonly nextLocked: boolean; + private prevLocked: boolean | null; + + constructor(opts: { + pageIndex: number; + runId?: string; + imageId?: string; + locked: boolean; + }) { + this.pageIndex = opts.pageIndex; + this.runId = opts.runId ?? null; + this.imageId = opts.imageId ?? null; + this.nextLocked = opts.locked; + this.prevLocked = null; + } + + apply(doc: EditorDocument): void { + const page = doc.page(this.pageIndex); + if (this.runId) { + const run = page.runs.find((r) => r.id === this.runId); + if (!run) return; + if (this.prevLocked === null) this.prevLocked = run.locked; + run.locked = this.nextLocked; + // Refresh the overlay snapshot so contentEditable/hit-test reflect + // the new lock state; lock is session-only, never dirties the page. + page.bumpRevision(); + return; + } + if (this.imageId) { + const img = page.images.find((i) => i.id === this.imageId); + if (!img) return; + if (this.prevLocked === null) this.prevLocked = img.locked; + img.locked = this.nextLocked; + page.bumpRevision(); + } + } + + revert(doc: EditorDocument): void { + if (this.prevLocked === null) return; + const page = doc.page(this.pageIndex); + if (this.runId) { + const run = page.runs.find((r) => r.id === this.runId); + if (run) run.locked = this.prevLocked; + page.bumpRevision(); + return; + } + if (this.imageId) { + const img = page.images.find((i) => i.id === this.imageId); + if (img) img.locked = this.prevLocked; + page.bumpRevision(); + } + } +} diff --git a/frontend/editor/src/core/tools/pdfTextEditor/commands/SetTextOutlineCommand.ts b/frontend/editor/src/core/tools/pdfTextEditor/commands/SetTextOutlineCommand.ts new file mode 100644 index 0000000000..9c69dc47ed --- /dev/null +++ b/frontend/editor/src/core/tools/pdfTextEditor/commands/SetTextOutlineCommand.ts @@ -0,0 +1,223 @@ +import type { Command } from "@app/tools/pdfTextEditor/commands/Command"; +import type { EditorDocument } from "@app/tools/pdfTextEditor/model/EditorDocument"; +import type { RGBA } from "@app/tools/pdfTextEditor/types"; +import { + applyInkState, + collectMemberPtrs, +} from "@app/tools/pdfTextEditor/commands/editTextHelpers"; + +/** Render modes that paint an outline; 0 is fill-only, 3 is invisible. */ +const FILL_ONLY = 0; +const FILL_AND_STROKE = 2; +/** Stroke-only (1) has to fall back to fill, or clearing hides the text. */ +const STROKING_MODES = new Set([1, 2]); + +interface MemberInk { + ptr: number; + renderMode: number; + stroke: RGBA | null; + strokeWidth: number; +} + +// Outline a run's glyphs, or clear it. Width alone is invisible, so this also +// moves the run between fill-only and fill-and-stroke render modes. +export class SetTextOutlineCommand implements Command { + readonly type = "set-text-outline"; + private readonly pageIndex: number; + private readonly runId: string; + private readonly nextStroke: RGBA | null; + private readonly nextWidth: number; + private prev: { + renderMode: number; + stroke: RGBA | null; + strokeWidth: number; + members: MemberInk[]; + } | null = null; + + constructor(opts: { + pageIndex: number; + runId: string; + /** Null clears the outline entirely. */ + stroke: RGBA | null; + width: number; + }) { + this.pageIndex = opts.pageIndex; + this.runId = opts.runId; + this.nextStroke = opts.stroke ? { ...opts.stroke } : null; + this.nextWidth = Math.max(0, opts.width); + } + + apply(doc: EditorDocument): void { + const page = doc.page(this.pageIndex); + const run = page.findRun(this.runId); + if (!run) return; + + if (this.prev === null) { + const seen = new Set(); + const members: MemberInk[] = []; + for (const ptr of collectMemberPtrs(run)) { + if (!ptr || seen.has(ptr)) continue; + seen.add(ptr); + members.push(readMemberInk(doc, ptr, run)); + } + this.prev = { + renderMode: run.renderMode, + stroke: run.stroke ? { ...run.stroke } : null, + strokeWidth: run.strokeWidth, + members, + }; + } + + const outlined = this.nextStroke !== null && this.nextWidth > 0; + // An invisible OCR layer stays invisible, and a clipping mode (4-7) keeps + // clipping - changing either would alter far more than an outline. + const preserveMode = run.renderMode === 3 || run.renderMode >= 4; + const nextMode = preserveMode + ? run.renderMode + : outlined + ? FILL_AND_STROKE + : STROKING_MODES.has(this.prev.renderMode) + ? FILL_ONLY + : run.renderMode; + + run.stroke = outlined && this.nextStroke ? { ...this.nextStroke } : null; + run.strokeWidth = outlined ? this.nextWidth : 0; + run.renderMode = nextMode; + run.dirty = true; + page.markDirty(); + this.writeMembers(doc, run, nextMode); + } + + revert(doc: EditorDocument): void { + const snapshot = this.prev; + if (!snapshot) return; + const page = doc.page(this.pageIndex); + const run = page.findRun(this.runId); + if (!run) return; + run.renderMode = snapshot.renderMode; + run.stroke = snapshot.stroke ? { ...snapshot.stroke } : null; + run.strokeWidth = snapshot.strokeWidth; + run.dirty = true; + page.markDirty(); + // Each member kept its own ink, exactly as with fills: a merged line can + // hold objects that were not all outlined the same way. + for (const member of snapshot.members) { + applyInkState(doc.module, [member.ptr], { + renderMode: member.renderMode, + stroke: member.stroke, + strokeWidth: member.strokeWidth, + }); + if (!member.stroke) clearStroke(doc, member.ptr); + } + page.markNeedsGenerate(); + } + + private writeMembers( + doc: EditorDocument, + run: { stroke: RGBA | null; strokeWidth: number }, + mode: number, + ): void { + const seen = new Set(); + for (const ptr of collectMemberPtrs(run as never)) { + if (!ptr || seen.has(ptr)) continue; + seen.add(ptr); + applyInkState(doc.module, [ptr], { + renderMode: mode, + stroke: run.stroke, + strokeWidth: run.strokeWidth, + }); + if (!run.stroke) clearStroke(doc, ptr); + } + doc.page(this.pageIndex).markNeedsGenerate(); + } + + /** One width-stepper drag must not fill the undo stack. */ + coalesceKey(): string { + return "set-text-outline"; + } + + describe(): string { + return `Set outline on ${this.runId}`; + } +} + +interface OutlineModule { + FPDFPageObj_GetStrokeColor?: ( + obj: number, + r: number, + g: number, + b: number, + a: number, + ) => boolean; + FPDFPageObj_GetStrokeWidth?: (obj: number, out: number) => boolean; + FPDFPageObj_SetStrokeColor?: ( + obj: number, + r: number, + g: number, + b: number, + a: number, + ) => boolean; + FPDFPageObj_SetStrokeWidth?: (obj: number, width: number) => boolean; + FPDFTextObj_GetTextRenderMode?: (obj: number) => number; +} + +function readMemberInk( + doc: EditorDocument, + ptr: number, + fallback: { renderMode: number; stroke: RGBA | null; strokeWidth: number }, +): MemberInk { + const m = doc.module; + const mod = m as unknown as OutlineModule; + let renderMode = fallback.renderMode; + try { + const v = mod.FPDFTextObj_GetTextRenderMode?.(ptr); + if (typeof v === "number" && v >= 0 && v <= 7) renderMode = v; + } catch { + /* keep the run-level value */ + } + const exports = m.pdfium.wasmExports as unknown as { + malloc: (n: number) => number; + free: (p: number) => void; + }; + const r = exports.malloc(4); + const g = exports.malloc(4); + const b = exports.malloc(4); + const a = exports.malloc(4); + const w = exports.malloc(4); + try { + let stroke: RGBA | null = null; + if (mod.FPDFPageObj_GetStrokeColor?.(ptr, r, g, b, a)) { + stroke = { + r: m.pdfium.getValue(r, "i32") & 0xff, + g: m.pdfium.getValue(g, "i32") & 0xff, + b: m.pdfium.getValue(b, "i32") & 0xff, + a: m.pdfium.getValue(a, "i32") & 0xff, + }; + } + let strokeWidth = 0; + if (mod.FPDFPageObj_GetStrokeWidth?.(ptr, w)) { + const raw = m.pdfium.getValue(w, "float"); + if (Number.isFinite(raw) && raw > 0) strokeWidth = raw; + } + return { ptr, renderMode, stroke, strokeWidth }; + } catch { + return { ptr, renderMode, stroke: null, strokeWidth: 0 }; + } finally { + exports.free(r); + exports.free(g); + exports.free(b); + exports.free(a); + exports.free(w); + } +} + +/** A zero-width transparent stroke is how PDFium expresses "no outline". */ +function clearStroke(doc: EditorDocument, ptr: number): void { + const mod = doc.module as unknown as OutlineModule; + try { + mod.FPDFPageObj_SetStrokeWidth?.(ptr, 0); + mod.FPDFPageObj_SetStrokeColor?.(ptr, 0, 0, 0, 0); + } catch { + /* best-effort */ + } +} diff --git a/frontend/editor/src/core/tools/pdfTextEditor/commands/TransformImageObjectCommand.ts b/frontend/editor/src/core/tools/pdfTextEditor/commands/TransformImageObjectCommand.ts new file mode 100644 index 0000000000..48455ceb90 --- /dev/null +++ b/frontend/editor/src/core/tools/pdfTextEditor/commands/TransformImageObjectCommand.ts @@ -0,0 +1,158 @@ +import type { Command } from "@app/tools/pdfTextEditor/commands/Command"; +import type { EditorDocument } from "@app/tools/pdfTextEditor/model/EditorDocument"; +import type { Affine } from "@app/tools/pdfTextEditor/types"; +import { retargetClipPath } from "@app/tools/pdfTextEditor/util/objectTransform"; + +// Apply an in-place transform to an image: rotate by 90° (CW or CCW), flip +// horizontally, or flip vertically. +export type ImageTransformMode = + | "rotate-cw" + | "rotate-ccw" + | "flip-h" + | "flip-v"; + +interface ImageMatrixSetterModule { + FPDFImageObj_SetMatrix?: ( + obj: number, + a: number, + b: number, + c: number, + d: number, + e: number, + f: number, + ) => boolean; +} + +export class TransformImageObjectCommand implements Command { + readonly type = "transform-image"; + private readonly pageIndex: number; + private readonly imageId: string; + private readonly mode: ImageTransformMode; + private prevMatrix: Affine | null; + + constructor(opts: { + pageIndex: number; + imageId: string; + mode: ImageTransformMode; + }) { + this.pageIndex = opts.pageIndex; + this.imageId = opts.imageId; + this.mode = opts.mode; + this.prevMatrix = null; + } + + apply(doc: EditorDocument): void { + const page = doc.page(this.pageIndex); + const img = page.findImage(this.imageId); + if (!img || !img.pdfiumObjPtr) return; + if (this.prevMatrix === null) this.prevMatrix = { ...img.matrix }; + const next = composeAboutCentre(img.matrix, this.mode); + setMatrix(doc, img.pdfiumObjPtr, next); + retargetClipPath(doc.module, img.pdfiumObjPtr, img.matrix, next); + img.matrix = next; + img.bounds = matrixBoundsAxisAligned(next); + img.dirty = true; + page.markDirty(); + page.markNeedsGenerate(); + } + + revert(doc: EditorDocument): void { + if (!this.prevMatrix) return; + const page = doc.page(this.pageIndex); + const img = page.findImage(this.imageId); + if (!img || !img.pdfiumObjPtr) return; + setMatrix(doc, img.pdfiumObjPtr, this.prevMatrix); + retargetClipPath(doc.module, img.pdfiumObjPtr, img.matrix, this.prevMatrix); + img.matrix = { ...this.prevMatrix }; + img.bounds = matrixBoundsAxisAligned(this.prevMatrix); + img.dirty = true; + page.markDirty(); + page.markNeedsGenerate(); + } +} + +// Compose `T(cx, cy) * Op * T(-cx, -cy) * M` where M is the input matrix, Op is +// the rotation/flip, and (cx, cy) is M's image-centre in page space. +function composeAboutCentre(m: Affine, mode: ImageTransformMode): Affine { + const cx = m.e + (m.a + m.c) / 2; + const cy = m.f + (m.b + m.d) / 2; + // Op transforms image-space (post-rotation/flip is applied to page-space + // output). + let oa: number, ob: number, oc: number, od: number; + switch (mode) { + case "rotate-ccw": + oa = 0; + ob = 1; + oc = -1; + od = 0; + break; + case "rotate-cw": + oa = 0; + ob = -1; + oc = 1; + od = 0; + break; + case "flip-h": + oa = -1; + ob = 0; + oc = 0; + od = 1; + break; + case "flip-v": + oa = 1; + ob = 0; + oc = 0; + od = -1; + break; + } + // M' = T * O * T * M = Concretely: new_a = oa*m.a + oc*m.b new_b = ob*m.a + + // od*m.b new_c = oa*m.c + oc*m.d new_d = ob*m.c + od*m.d. + return { + a: oa * m.a + oc * m.b, + b: ob * m.a + od * m.b, + c: oa * m.c + oc * m.d, + d: ob * m.c + od * m.d, + e: oa * (m.e - cx) + oc * (m.f - cy) + cx, + f: ob * (m.e - cx) + od * (m.f - cy) + cy, + }; +} + +// Axis-aligned bounding box of the image's projected 1x1 square under matrix m. +function matrixBoundsAxisAligned(m: Affine): { + x: number; + y: number; + width: number; + height: number; +} { + const corners: Array<[number, number]> = [ + [0, 0], + [1, 0], + [0, 1], + [1, 1], + ]; + const xs: number[] = []; + const ys: number[] = []; + for (const [u, v] of corners) { + xs.push(m.a * u + m.c * v + m.e); + ys.push(m.b * u + m.d * v + m.f); + } + const minX = Math.min(...xs); + const minY = Math.min(...ys); + return { + x: minX, + y: minY, + width: Math.max(...xs) - minX, + height: Math.max(...ys) - minY, + }; +} + +function setMatrix(doc: EditorDocument, objPtr: number, m: Affine): void { + const fn = (doc.module as unknown as ImageMatrixSetterModule) + .FPDFImageObj_SetMatrix; + if (!fn) return; + try { + fn(objPtr, m.a, m.b, m.c, m.d, m.e, m.f); + } catch { + /* best-effort */ + } +} diff --git a/frontend/editor/src/core/tools/pdfTextEditor/commands/UngroupParagraphCommand.ts b/frontend/editor/src/core/tools/pdfTextEditor/commands/UngroupParagraphCommand.ts new file mode 100644 index 0000000000..389bd0cafc --- /dev/null +++ b/frontend/editor/src/core/tools/pdfTextEditor/commands/UngroupParagraphCommand.ts @@ -0,0 +1,157 @@ +import type { Command } from "@app/tools/pdfTextEditor/commands/Command"; +import type { EditorDocument } from "@app/tools/pdfTextEditor/model/EditorDocument"; +import { + cloneParagraphLineSlot, + type ParagraphLineSlot, + TextRun, +} from "@app/tools/pdfTextEditor/model/TextRun"; + +/** Split a paragraph-grouped run back into one editable run per source line. */ +interface RepSnapshot { + text: string; + bounds: { x: number; y: number; width: number; height: number }; + paragraphLineHeight: number; + paragraphMemberPtrs: number[]; + paragraphMemberContainers: number[]; + paragraphMemberFs: number[]; + paragraphLeafPtrs: number[]; + paragraphLeafContainers: number[]; + paragraphLineSlots: ParagraphLineSlot[]; +} + +export class UngroupParagraphCommand implements Command { + readonly type = "ungroup-paragraph"; + private readonly pageIndex: number; + private readonly runId: string; + private prev: RepSnapshot | null = null; + private createdRunIds: string[] = []; + + constructor(opts: { pageIndex: number; runId: string }) { + this.pageIndex = opts.pageIndex; + this.runId = opts.runId; + } + + /** Run IDs produced by the split (rep line + one per extra source line). */ + get resultRunIds(): string[] { + return this.createdRunIds; + } + + apply(doc: EditorDocument): void { + const page = doc.page(this.pageIndex); + const rep = page.findRun(this.runId); + if (!rep) return; + if (rep.paragraphMemberPtrs.length < 2) return; + + this.prev = { + text: rep.text, + bounds: { ...rep.bounds }, + paragraphLineHeight: rep.paragraphLineHeight, + paragraphMemberPtrs: [...rep.paragraphMemberPtrs], + paragraphMemberContainers: [...rep.paragraphMemberContainers], + paragraphMemberFs: [...rep.paragraphMemberFs], + paragraphLeafPtrs: [...rep.paragraphLeafPtrs], + paragraphLeafContainers: [...rep.paragraphLeafContainers], + paragraphLineSlots: rep.paragraphLineSlots.map(cloneParagraphLineSlot), + }; + + const ptrs = rep.paragraphMemberPtrs; + const fs = rep.paragraphMemberFs; + const containers = rep.paragraphMemberContainers; + // Prefer per-line slots: their startChar/endChar ranges split the text + // correctly even for SOFT-wrapped paragraphs. + const slots = rep.paragraphLineSlots; + const useSlots = slots.length >= 2 && slots.length === ptrs.length; + const lines = useSlots + ? slots.map((s) => rep.text.slice(s.startChar, s.endChar)) + : rep.text.split(/\r?\n/); + const n = Math.min(lines.length, ptrs.length); + const newRuns: TextRun[] = []; + const perLineHeight = + rep.paragraphLineHeight > 0 + ? rep.paragraphLineHeight + : rep.fontSize * 1.2; + for (let i = 0; i < n; i++) { + const baselineY = fs[i] ?? rep.matrix.f - i * perLineHeight; + const id = `${rep.id}-line-${i}-${ptrs[i] || "stub"}`; + const lineHeight = rep.fontSize; + const r = new TextRun({ + id, + pageIndex: page.index, + pdfiumObjPtr: ptrs[i] || 0, + bounds: { + x: rep.bounds.x, + y: baselineY - rep.fontSize * 0.2, + width: rep.bounds.width, + height: lineHeight, + }, + matrix: { a: 1, b: 0, c: 0, d: 1, e: rep.bounds.x, f: baselineY }, + text: lines[i] ?? "", + fontId: rep.fontId, + fontSize: rep.fontSize, + fill: { ...rep.fill }, + fontSubset: rep.fontSubset, + }); + r.containerPtr = containers[i] ?? 0; + newRuns.push(r); + } + this.createdRunIds = newRuns.map((r) => r.id); + + rep.paragraphMemberPtrs = []; + rep.paragraphMemberContainers = []; + rep.paragraphMemberFs = []; + rep.paragraphLeafPtrs = []; + rep.paragraphLeafContainers = []; + rep.paragraphLineSlots = []; + rep.paragraphLineHeight = 0; + rep.text = lines[0] ?? ""; + rep.bounds = { + x: rep.bounds.x, + y: (fs[0] ?? rep.matrix.f) - rep.fontSize * 0.2, + width: rep.bounds.width, + height: rep.fontSize, + }; + rep.matrix = { ...rep.matrix, f: fs[0] ?? rep.matrix.f }; + + // Replace rep with rep + (n-1) new lines; the first line stays on rep. + const tail = newRuns.slice(1); + const idx = page.runs.findIndex((r) => r.id === rep.id); + if (idx >= 0) { + const next = [...page.runs]; + next.splice(idx + 1, 0, ...tail); + page.setRuns(next); + } + // Bump revision so the dirty-only resnapshot republishes the page - + // this command only mutates the in-memory run model. + page.markDirty(); + } + + revert(doc: EditorDocument): void { + if (!this.prev) return; + const page = doc.page(this.pageIndex); + const rep = page.findRun(this.runId); + if (!rep) return; + rep.text = this.prev.text; + rep.bounds = { ...this.prev.bounds }; + rep.matrix = { + ...rep.matrix, + f: this.prev.paragraphMemberFs[0] ?? rep.matrix.f, + }; + rep.paragraphLineHeight = this.prev.paragraphLineHeight; + rep.paragraphMemberPtrs = [...this.prev.paragraphMemberPtrs]; + rep.paragraphMemberContainers = [...this.prev.paragraphMemberContainers]; + rep.paragraphMemberFs = [...this.prev.paragraphMemberFs]; + rep.paragraphLeafPtrs = [...this.prev.paragraphLeafPtrs]; + rep.paragraphLeafContainers = [...this.prev.paragraphLeafContainers]; + rep.paragraphLineSlots = this.prev.paragraphLineSlots.map( + cloneParagraphLineSlot, + ); + const tailIds = new Set(this.createdRunIds.slice(1)); + page.setRuns(page.runs.filter((r) => !tailIds.has(r.id))); + page.markDirty(); + this.createdRunIds = []; + } + + describe(): string { + return `Ungroup paragraph ${this.runId}`; + } +} diff --git a/frontend/editor/src/core/tools/pdfTextEditor/commands/editTextHelpers.ts b/frontend/editor/src/core/tools/pdfTextEditor/commands/editTextHelpers.ts new file mode 100644 index 0000000000..7e1cd1b865 --- /dev/null +++ b/frontend/editor/src/core/tools/pdfTextEditor/commands/editTextHelpers.ts @@ -0,0 +1,1565 @@ +import { readUtf16, writeUtf16 } from "@app/services/pdfiumService"; +import type { + ParagraphLineSlot, + TextRun, +} from "@app/tools/pdfTextEditor/model/TextRun"; +import type { Page } from "@app/tools/pdfTextEditor/model/Page"; +import type { EditorDocument } from "@app/tools/pdfTextEditor/model/EditorDocument"; +import type { WrappedPdfiumModule } from "@embedpdf/pdfium"; +import type { RGBA } from "@app/tools/pdfTextEditor/types"; +import { + emitCharcodeEvent, + findFontForChar, + fontIsReusable, + setCharcodesOn, + styleClassFromName, + tryResolveCharcodes, +} from "@app/tools/pdfTextEditor/charcode/charcodeRegistry"; +import { getActiveCharcodeStrategy } from "@app/tools/pdfTextEditor/charcode/CharcodeStrategy"; +import { emitFallbackTextObject } from "@app/tools/pdfTextEditor/util/fallbackFont"; +import { emitDeviceFontTextObject } from "@app/tools/pdfTextEditor/util/deviceFontEmbed"; +import { nearestStandardFont } from "@app/tools/pdfTextEditor/util/fontFamily"; + +// Remove a PAGE-level object and FREE its PDFium allocation. +// `FPDFPage_RemoveObject` only detaches the object. +export function removeAndDestroyObject( + m: WrappedPdfiumModule, + pagePtr: number, + ptr: number, +): void { + if (!ptr) return; + try { + m.FPDFPage_RemoveObject(pagePtr, ptr); + } catch { + /* best-effort */ + } + try { + m.FPDFPageObj_Destroy(ptr); + } catch { + /* best-effort */ + } +} + +// Pointers freshly created by the per-char BACKEND emit branch in +// `emitTextLine`. +const perCharBranchPtrs = new Set(); + +// (fontPtr:char) pairs a read-back has PROVEN render faithfully via SetText. +const readBackValidated = new Set(); + +/** Caller check: was this ptr produced by the per-char emit branch? */ +export function isVerifiedPerCharPtr(ptr: number): boolean { + return perCharBranchPtrs.has(ptr); +} + +/** Doc-scoped reset: PDFium reuses freed pointers across documents. */ +export function resetPerCharBranchPtrs(): void { + perCharBranchPtrs.clear(); + readBackValidated.clear(); +} + +/** Test-only: clear the verified-ptr set between cases. */ +export function _clearVerifiedPerCharPtrsForTests(): void { + resetPerCharBranchPtrs(); +} + +// Characters that an edit could NOT represent and silently dropped: the source +// font couldn't render them. +const droppedBase14Chars = new Set(); + +/** Visible chars dropped this session because nothing could render them. */ +export function getDroppedBase14Chars(): string[] { + return [...droppedBase14Chars]; +} + +/** Doc-scoped reset for the dropped-char record. */ +export function resetDroppedBase14Chars(): void { + droppedBase14Chars.clear(); +} + +/** Test-only alias for {@link resetDroppedBase14Chars}. */ +export function _clearDroppedBase14CharsForTests(): void { + resetDroppedBase14Chars(); +} + +/** Record every VISIBLE char present in `original` but missing from `kept`. */ +function recordDroppedChars(original: string, kept: string): void { + const keptSet = new Set(kept); + for (const ch of original) { + if (!keptSet.has(ch) && ch.trim().length > 0) droppedBase14Chars.add(ch); + } +} + +/** True when every character in `text` is also present in `pool`. */ +export function everyCharIn(text: string, pool: string): boolean { + const set = new Set(pool); + for (const c of text) if (!set.has(c)) return false; + return true; +} + +// Whether a font can encode a given character, keyed by font pointer. Replace +// all rewrites every matching run, so resolving per run made the click block. +const charCoverage = new Map>(); + +/** Doc-scoped reset: PDFium reuses font pointers across documents. */ +export function resetCharCoverageCache(): void { + charCoverage.clear(); +} + +// True when the emit path will map EVERY char in this font. Same condition +// emitTextLine uses to take its setCharcodes branch, so a true here means the +// reuse really will render rather than fall through to raw SetText. +export function charcodesResolveFully( + m: WrappedPdfiumModule, + fontPtr: number, + text: string, + pagePtr: number, + docPtr: number, +): boolean { + if (!fontPtr || !text) return false; + let perFont = charCoverage.get(fontPtr); + if (!perFont) { + perFont = new Map(); + charCoverage.set(fontPtr, perFont); + } + // Distinct characters only: a long string costs no more than its alphabet. + for (const ch of new Set([...text])) { + const known = perFont.get(ch); + if (known === false) return false; + if (known === true) continue; + let ok = false; + try { + const resolved = tryResolveCharcodes( + fontPtr, + ch, + { module: m, pagePtr, docPtr }, + true, + ); + const r = resolved?.result; + ok = !!r && r.coverage === 1 && r.charcodes.length === 1; + } catch { + ok = false; + } + // Only memoise a POSITIVE result. A miss here can simply mean the + // charcode cache was cold or the backend was briefly unreachable, and + // caching that as "this font cannot encode this character" made the + // failure permanent for the session. + if (ok) perFont.set(ch, true); + else return false; + } + return true; +} + +/** Strip characters a base-14 (WinAnsi) font cannot render. */ +export function sanitizeForBase14(text: string): string { + let out = ""; + for (const ch of text) { + const cp = ch.codePointAt(0) ?? 0; + if (cp === 0x09 || cp === 0x0a || cp === 0x0d) { + out += ch; + } else if (cp < 0x20 || cp === 0x7f || (cp >= 0x80 && cp <= 0x9f)) { + // C0/DEL/C1 controls are un-encodable in WinAnsi - drop them. + continue; + } else if (cp === 0x00a0) { + out += " "; + } else if (cp <= 0xff) { + out += ch; + } + // else: unrepresentable in base-14 - drop it (no tofu). + } + return out; +} + +/** Every PDFium pointer that backs a run. */ +export function collectMemberPtrs(run: TextRun): number[] { + if (run.paragraphLeafPtrs.length > 0) return run.paragraphLeafPtrs; + if (run.paragraphMemberPtrs.length > 0) return run.paragraphMemberPtrs; + if (run.mergedFromPtrs.length > 0) return run.mergedFromPtrs; + return [run.pdfiumObjPtr]; +} + +// Parallel map from member pointer to its form-xobject container (zero for +// page-level members). +export function collectContainersByPtr(run: TextRun): Map { + const map = new Map(); + if (run.paragraphLeafPtrs.length > 0) { + run.paragraphLeafPtrs.forEach((ptr, i) => { + map.set(ptr, run.paragraphLeafContainers[i] ?? 0); + }); + return map; + } + if (run.paragraphMemberPtrs.length > 0) { + run.paragraphMemberPtrs.forEach((ptr, i) => { + map.set(ptr, run.paragraphMemberContainers[i] ?? 0); + }); + return map; + } + for (const ptr of run.mergedFromPtrs) map.set(ptr, run.containerPtr); + if (run.pdfiumObjPtr) map.set(run.pdfiumObjPtr, run.containerPtr); + return map; +} + +interface FormRemovalModule { + FPDFFormObj_RemoveObject?: (form: number, obj: number) => boolean; +} + +/** Best-effort removal of every pointer in `ptrs`. */ +export function removeMemberPtrs( + m: WrappedPdfiumModule, + page: Page, + ptrs: number[], + containerByPtr: Map, + fallbackContainerPtr: number, +): boolean { + if (ptrs.length === 0) return false; + const formMod = m as unknown as FormRemovalModule; + let allOk = true; + for (const ptr of ptrs) { + if (!ptr) { + allOk = false; + continue; + } + const container = containerByPtr.get(ptr) ?? fallbackContainerPtr; + let ok: boolean; + if (container && formMod.FPDFFormObj_RemoveObject) { + try { + ok = !!formMod.FPDFFormObj_RemoveObject(container, ptr); + } catch { + ok = false; + } + } else { + try { + m.FPDFPage_RemoveObject(page.pagePtr, ptr); + ok = true; + } catch { + ok = false; + } + } + if (!ok) allOk = false; + } + return allOk; +} + +interface CreatedTextOptions { + doc: EditorDocument; + page: Page; + text: string; + x: number; + y: number; + fontSize: number; + fill: { r: number; g: number; b: number; a: number }; + /** When non-zero, reuse the source font instead of base-14. */ + originalFontPtr: number; + /** Whether the reused source font is a SUBSET font. */ + originalFontSubset?: boolean; + /** Base-14 family used when `originalFontPtr` is zero. Defaults to Helvetica. */ + fallbackFamily?: string; + /** The source run's PDF text render mode (Tr). */ + renderMode?: number; + /** Glyph outline colour; only paints under a stroking render mode. */ + stroke?: RGBA | null; + strokeWidth?: number; + /** The run's on-page rotation (normalised cos/sin of its text matrix). */ + rotation?: { cos: number; sin: number }; + // Extra advance per glyph in PDF points - the source run's rendered + // letter-spacing (Tc), inferred at read time. + charSpacingPt?: number; + // Optional sink for the text each returned pointer carries, parallel to the + // return value. The emit branches chunk by word, by character, or not at all, + // so callers that must map pointers back onto the source string cannot guess + // it - and reading it back costs a full page text extraction per line. + outTexts?: string[]; +} + +interface CreateTextObjModule { + FPDFPageObj_CreateTextObj?: ( + doc: number, + font: number, + size: number, + ) => number; +} + +// NOTE on spaces: PDFium normalises consecutive ASCII spaces inside a single +// text object, and base-14 Helvetica maps NBSP to 0xFF, which renders as junk. + +let measureCanvas: HTMLCanvasElement | null = null; + +/** Hidden canvas used to measure CSS-Helvetica advance widths. */ +function measureCtx(): CanvasRenderingContext2D | null { + if (typeof document === "undefined") return null; + if (!measureCanvas) measureCanvas = document.createElement("canvas"); + return measureCanvas.getContext("2d"); +} + +// Map a base-14 PostScript name to a CSS font spec the browser actually has. +export function cssFontSpecFor(fontFamily: string, sizePx: number): string { + const f = fontFamily.toLowerCase(); + const bold = f.includes("bold") ? "bold " : ""; + const italic = f.includes("italic") || f.includes("oblique") ? "italic " : ""; + let stack = "Helvetica, Arial, sans-serif"; + if (f.startsWith("times")) stack = "'Times New Roman', Times, serif"; + else if (f.startsWith("courier")) stack = "'Courier New', Courier, monospace"; + return `${italic}${bold}${sizePx}px ${stack}`; +} + +/** Measure the natural advance width of `s` in PDF points. */ +function measureAdvancePt( + text: string, + fontFamily: string, + fontSizePt: number, +): number { + const ctx = measureCtx(); + if (!ctx) return text.length * fontSizePt * 0.5; + ctx.font = cssFontSpecFor(fontFamily, fontSizePt); + return ctx.measureText(text).width; +} + +// Per-page cache of each char's ON-PAGE rendered advance (per em), keyed +// pagePtr -> fontPtr -> unicode -> advanceEm. +const onPageAdvCache = new Map>>(); + +interface LooseBoxModule { + FPDFText_LoadPage?: (page: number) => number; + FPDFText_ClosePage?: (tp: number) => void; + FPDFText_CountChars?: (tp: number) => number; + FPDFText_GetUnicode?: (tp: number, i: number) => number; + FPDFText_GetTextObject?: (tp: number, i: number) => number; + FPDFTextObj_GetFont?: (obj: number) => number; + FPDFText_GetFontSize?: (tp: number, i: number) => number; + FPDFText_GetLooseCharBox?: (tp: number, i: number, rect: number) => boolean; + FPDFText_GetCharOrigin?: ( + tp: number, + i: number, + x: number, + y: number, + ) => boolean; +} + +// A measured advance below this many ems is treated as an ink box mistaken for +// an advance - that collapse is what stacked Type 3 glyphs onto each other. +// +// This is a deliberate trade-off, not a safe floor: real faces do go under it +// (Garamond's "i" is 0.177em), and such a glyph falls through to an estimated +// metric that can be ~25% wide. Lowering the threshold is not the fix - the +// Type 3 ink boxes it exists to reject measure about 0.12em, so there is no +// gap between the two populations to separate them cleanly. +const MIN_PLAUSIBLE_ADVANCE_EM = 0.18; +// Above this, the "advance" swallowed a word gap or a Td jump. +const MAX_PLAUSIBLE_ADVANCE_EM = 2; + +/** Baseline origin of char `idx` in page points, or null when unreadable. */ +function charOriginPt( + m: import("@embedpdf/pdfium").WrappedPdfiumModule, + tp: number, + idx: number, +): { x: number; y: number } | null { + const mod = m as unknown as LooseBoxModule; + if (!mod.FPDFText_GetCharOrigin) return null; + // FPDFText_GetCharOrigin takes two double* out-params. + const buf = m.pdfium.wasmExports.malloc(16); + try { + if (!mod.FPDFText_GetCharOrigin(tp, idx, buf, buf + 8)) return null; + return { + x: m.pdfium.getValue(buf, "double"), + y: m.pdfium.getValue(buf + 8, "double"), + }; + } catch { + return null; + } finally { + m.pdfium.wasmExports.free(buf); + } +} + +function looseBoxAdvancePt( + m: import("@embedpdf/pdfium").WrappedPdfiumModule, + tp: number, + idx: number, +): number | null { + const mod = m as unknown as LooseBoxModule; + if (!mod.FPDFText_GetLooseCharBox) return null; + const wasm = ( + m.pdfium as unknown as { + wasmExports: { malloc: (n: number) => number; free: (p: number) => void }; + } + ).wasmExports; + const buf = wasm.malloc(16); // FS_RECT = 4 floats {left, top, right, bottom} + try { + if (!mod.FPDFText_GetLooseCharBox(tp, idx, buf)) return null; + const heap = (m.pdfium as unknown as { HEAPU8: Uint8Array }).HEAPU8; + const f32 = new Float32Array(heap.buffer, buf, 4); + const width = f32[2] - f32[0]; + return width > 0 ? width : null; + } catch { + return null; + } finally { + wasm.free(buf); + } +} + +/** |scale| of a page object's matrix (1 when unreadable). */ +function objMatrixScale( + m: import("@embedpdf/pdfium").WrappedPdfiumModule, + objPtr: number, +): number { + const buf = m.pdfium.wasmExports.malloc(6 * 4); + try { + if (!m.FPDFPageObj_GetMatrix(objPtr, buf)) return 1; + const a = m.pdfium.getValue(buf, "float"); + const b = m.pdfium.getValue(buf + 4, "float"); + const s = Math.hypot(a, b); + return s > 0 ? s : 1; + } catch { + return 1; + } finally { + m.pdfium.wasmExports.free(buf); + } +} + +function buildOnPageAdvMap( + m: import("@embedpdf/pdfium").WrappedPdfiumModule, + pagePtr: number, +): Map> { + const mod = m as unknown as LooseBoxModule; + const out = new Map>(); + if ( + !mod.FPDFText_LoadPage || + !mod.FPDFText_CountChars || + !mod.FPDFText_GetUnicode || + !mod.FPDFText_GetTextObject || + !mod.FPDFTextObj_GetFont || + !mod.FPDFText_GetFontSize + ) { + return out; + } + const tp = mod.FPDFText_LoadPage(pagePtr); + if (!tp) return out; + // FPDFText_GetFontSize returns the raw Tf operand, but many producers set Tf + // 1 and carry the real size in the text matrix. + const scaleByObj = new Map(); + try { + const count = mod.FPDFText_CountChars(tp); + for (let i = 0; i < count; i++) { + const u = mod.FPDFText_GetUnicode(tp, i); + if (!u) continue; + const obj = mod.FPDFText_GetTextObject(tp, i); + if (!obj) continue; + let font = 0; + try { + font = mod.FPDFTextObj_GetFont(obj); + } catch { + /* skip */ + } + if (!font) continue; + let fm = out.get(font); + if (!fm) { + fm = new Map(); + out.set(font, fm); + } + if (fm.has(u)) continue; + const fs = mod.FPDFText_GetFontSize(tp, i); + if (!fs || fs <= 0) continue; + let scale = scaleByObj.get(obj); + if (scale === undefined) { + scale = objMatrixScale(m, obj); + scaleByObj.set(obj, scale); + } + const effFs = fs * scale; + if (!effFs || effFs <= 0) continue; + // The loose char box is the glyph's own advance, which is what the emit + // path wants: it re-applies the run's letter-spacing itself. On Type 3 + // faces (Figma/Skia exports) PDFium degrades it to the tight ink box, + // which collapses every advance and stacks the glyphs on re-emit - so + // an implausible value falls through to the pen movement on the page. + // That gap includes any Tc the producer used, but an advance that is + // slightly too wide beats one that is zero. + let advEm: number | null = null; + const adv = looseBoxAdvancePt(m, tp, i); + const looseEm = adv == null ? null : adv / effFs; + if ( + looseEm != null && + looseEm >= MIN_PLAUSIBLE_ADVANCE_EM && + looseEm <= MAX_PLAUSIBLE_ADVANCE_EM + ) { + advEm = looseEm; + } else { + const here = charOriginPt(m, tp, i); + const next = i + 1 < count ? charOriginPt(m, tp, i + 1) : null; + if (here && next && Math.abs(next.y - here.y) < 0.5) { + const delta = (next.x - here.x) / effFs; + if ( + delta >= MIN_PLAUSIBLE_ADVANCE_EM && + delta <= MAX_PLAUSIBLE_ADVANCE_EM + ) { + advEm = delta; + } + } + } + // No trustworthy measurement: leave the char unmapped so the caller + // falls back to font metrics rather than advancing by ~nothing. + if (advEm == null) continue; + fm.set(u, advEm); + } + } finally { + try { + mod.FPDFText_ClosePage?.(tp); + } catch { + /* best-effort */ + } + } + return out; +} + +/** On-page rendered advance (per em) of `ch` in `font`, or null if absent. */ +function onPageAdvanceEm( + m: import("@embedpdf/pdfium").WrappedPdfiumModule, + pagePtr: number, + font: number, + ch: string, +): number | null { + if (!font) return null; + let pageMap = onPageAdvCache.get(pagePtr); + if (!pageMap) { + pageMap = buildOnPageAdvMap(m, pagePtr); + onPageAdvCache.set(pagePtr, pageMap); + } + const cp = ch.codePointAt(0) ?? 0; + return pageMap.get(font)?.get(cp) ?? null; +} + +/** + * Build the page's advance map now, while every source glyph is still on the + * page. + * + * The map is the only place a Type 3 glyph's real advance can come from, and + * an edit removes the objects it is measured off. Warming it first is what + * lets a re-emit keep the original face instead of collapsing. + */ +export function warmOnPageAdvances( + m: import("@embedpdf/pdfium").WrappedPdfiumModule, + pagePtr: number, +): void { + if (!pagePtr || onPageAdvCache.has(pagePtr)) return; + try { + onPageAdvCache.set(pagePtr, buildOnPageAdvMap(m, pagePtr)); + } catch { + /* best-effort - callers fall back to font metrics */ + } +} + +/** Drop the per-page on-page-advance cache. */ +export function resetOnPageAdvCache(): void { + onPageAdvCache.clear(); +} + +/** Test-only alias for {@link resetOnPageAdvCache}. */ +export function _clearOnPageAdvCacheForTests(): void { + resetOnPageAdvCache(); +} + +// Split a line into one chunk per word with the trailing whitespace stored as +// an explicit `gapAfterPt`. +export interface WordChunk { + text: string; + gapAfterPt: number; + /** How many whitespace chars the gap after this chunk represents. */ + gapCharCount: number; +} +export function splitIntoWordChunks( + line: string, + fontFamily: string, + fontSizePt: number, +): WordChunk[] { + const chunks: WordChunk[] = []; + // Any run of 1+ whitespace becomes a chunk boundary. + const gapRe = /\s+/g; + let leadingGapPt = 0; + let leadingGapChars = 0; + let lastIdx = 0; + let m: RegExpExecArray | null; + while ((m = gapRe.exec(line)) !== null) { + const before = line.slice(lastIdx, m.index); + const gapText = m[0]; + const gapPt = measureAdvancePt(gapText, fontFamily, fontSizePt); + if (before.length === 0) { + // Whitespace at the very start of `line`, or two whitespace runs + // back-to-back with no non-space char between. + leadingGapPt += gapPt; + leadingGapChars += gapText.length; + } else { + chunks.push({ + text: before, + gapAfterPt: gapPt, + gapCharCount: gapText.length, + }); + } + lastIdx = gapRe.lastIndex; + } + // Trailing non-whitespace tail. + if (lastIdx < line.length) { + chunks.push({ text: line.slice(lastIdx), gapAfterPt: 0, gapCharCount: 0 }); + } + // Leading whitespace is exposed as a side field the caller folds into + // the initial cursor (it can't live in any chunk's gapAfterPt). + const side = chunks as WordChunk[] & { + leadingGapPt?: number; + leadingGapChars?: number; + }; + side.leadingGapPt = leadingGapPt; + side.leadingGapChars = leadingGapChars; + return chunks; +} + +/** Insert one or more text objects representing `opts.text`. */ +/** Normalised rotation of a text matrix, or undefined for upright text. */ +export function rotationFromMatrix(matrix: { + a: number; + b: number; + c?: number; + d?: number; +}): { cos: number; sin: number } | undefined { + const scale = Math.hypot(matrix.a, matrix.b); + if (!scale) return undefined; + const cos = matrix.a / scale; + const sin = matrix.b / scale; + // a,b alone cannot tell a mirrored generator from upright text - both read + // sin~=0 / cos>0 - so the determinant decides. + const c = matrix.c ?? 0; + const d = matrix.d ?? scale; + const mirrored = matrix.a * d - matrix.b * c < 0; + if (Math.abs(sin) < 1e-4 && cos > 0 && !mirrored) return undefined; + return { cos, sin }; +} + +// The rotation a NEW object needs so it reads upright on a page displayed with +// `/Rotate` (quarter-turns CW). +export function counterPageRotation( + rotateQuarterTurnsCw: number, +): { cos: number; sin: number } | undefined { + switch (((rotateQuarterTurnsCw % 4) + 4) % 4) { + case 1: + return { cos: 0, sin: 1 }; + case 2: + return { cos: -1, sin: 0 }; + case 3: + return { cos: 0, sin: -1 }; + default: + return undefined; + } +} + +/** Rotate a page object about (ax, ay). Identity (no-op) when cos=1, sin=0. */ +export function rotateObjectAbout( + m: WrappedPdfiumModule, + ptr: number, + ax: number, + ay: number, + cos: number, + sin: number, +): void { + m.FPDFPageObj_Transform( + ptr, + cos, + sin, + -sin, + cos, + ax - ax * cos + ay * sin, + ay - ax * sin - ay * cos, + ); +} + +export function emitTextLine(opts: CreatedTextOptions): number[] { + const m = opts.doc.module; + const size = Math.max(4, opts.fontSize); + const family = opts.fallbackFamily ?? "Helvetica"; + const m2 = m as unknown as CreateTextObjModule; + const canReuse = opts.originalFontPtr !== 0 && !!m2.FPDFPageObj_CreateTextObj; + + // Words are laid out horizontally from (opts.x, opts.y). + const withRotation = (ptrs: number[]): number[] => { + const rot = opts.rotation; + if (rot) { + for (const p of ptrs) { + if (p) rotateObjectAbout(m, p, opts.x, opts.y, rot.cos, rot.sin); + } + } + // Every successful emit funnels through here, so this is the one place to + // re-apply the source run's ink state - new objects default to a flat fill. + applyInkState(m, ptrs, opts); + return ptrs; + }; + + // Emit ONE word at (x, y) and return its pointer (0 on failure). + const emitWord = (text: string, x: number): number => { + // base-14 can only render Latin-1; drop the rest so PDFium never emits + // U+00FF tofu. + const base14Text = sanitizeForBase14(text); + const newBase14 = (): number => { + const ptr = m.FPDFPageObj_NewTextObj(opts.doc.docPtr, family, size); + if (ptr) return ptr; + // PDFium only knows the standard font names, so any other family fails + // here. Substituting is what editors do; returning 0 would drop the text. + const substitute = nearestStandardFont(family); + return substitute === family + ? 0 + : m.FPDFPageObj_NewTextObj(opts.doc.docPtr, substitute, size); + }; + const emitBase14 = (): number => { + // A pre-warmed device font emits with its REAL face. Standard names skip + // this and a cold cache returns 0, so existing emits are unchanged. + if (nearestStandardFont(family) !== family) { + const dp = emitDeviceFontTextObject( + opts.doc, + opts.page, + family, + text, + size, + opts.fill, + x, + opts.y, + ); + if (dp) return dp; + } + // Some chars are outside base-14's Latin-1 range. + if ([...text].length > [...base14Text].length) { + const fp = emitFallbackTextObject( + opts.doc, + opts.page, + text, + size, + opts.fill, + x, + opts.y, + ); + if (fp) return fp; + // The bundled Noto fallback couldn't render the non-Latin chars either, + // so the base-14 emit below drops them. + recordDroppedChars(text, base14Text); + } + if (base14Text.length === 0) return 0; // nothing representable - drop + const p = newBase14(); + if (!p) return 0; + setTextOn(m, p, base14Text); + applyFillAndPos(m, opts.page, p, opts.fill, x, opts.y); + return p; + }; + if (!canReuse) { + // Still record the attempt: this is the only signal that an edit fell + // back instead of reusing the source face. + emitCharcodeEvent({ + timestamp: 0, + strategy: getActiveCharcodeStrategy(), + text, + fontPtr: opts.originalFontPtr, + resolved: [], + missing: [...text], + note: + opts.originalFontPtr !== 0 + ? "source font cannot author glyphs (Type 3 / no font program) - substituting" + : "no source font available (Helvetica fresh emit)", + outcome: "no-font", + }); + return emitBase14(); + } + + const ptr = m2.FPDFPageObj_CreateTextObj!( + opts.doc.docPtr, + opts.originalFontPtr, + size, + ); + if (!ptr) return emitBase14(); + // Reuse path: resolve real font charcodes so the embedded subset font + // renders the chars; falls back to SetText internally. + const strategyUsed = writeViaCharcodesOrSetText(ptr, text); + applyFillAndPos(m, opts.page, ptr, opts.fill, x, opts.y); + // A whole-word SetCharcodes write via the BACKEND resolver used known-good + // (font, charcode) pairs PDFBox validated, so the glyph is real. + if (strategyUsed === "backend") return ptr; + const right = measureObjRightEdgePt(m, ptr); + const visible = text.replace(/\s+/g, "").length; + // Narrowest base-14 glyph ("i") is ~0.22em; anything well under ~0.15em + // per visible char means the reused font produced .notdef / 0-width. + const minExpected = visible * size * 0.15; + if (visible > 0 && right - x < minExpected) { + // Discard the .notdef object and free it (we re-emit in base-14 next). + removeAndDestroyObject(m, opts.page.pagePtr, ptr); + return emitBase14(); + } + // Read-back validation for a source-font SetText. + if (strategyUsed === null) { + // Throttle: chars a previous read-back already proved this font renders + // faithfully never need re-checking. + const visibleChars = [...text].filter((c) => c.trim().length > 0); + const allProven = + opts.originalFontPtr !== 0 && + visibleChars.every((c) => + readBackValidated.has(`${opts.originalFontPtr}:${c}`), + ); + if (!allProven) { + const got = readBackTextObj(m, opts.page.pagePtr, ptr); + if (got !== null) { + const norm = (s: string) => s.replace(/\s+/g, ""); + if (norm(got) !== norm(text)) { + removeAndDestroyObject(m, opts.page.pagePtr, ptr); + return emitBase14(); + } + if (opts.originalFontPtr) { + for (const c of visibleChars) { + readBackValidated.add(`${opts.originalFontPtr}:${c}`); + } + } + } + } + } + // Self-validate an UNTRUSTED charcode GUESS. + if ( + (strategyUsed === "content-stream" || strategyUsed === "cmap") && + opts.originalFontPtr + ) { + let expected = 0; + let known = 0; + for (const ch of text) { + if (/\s/.test(ch)) continue; + const em = onPageAdvanceEm( + m, + opts.page.pagePtr, + opts.originalFontPtr, + ch, + ); + if (em != null) { + expected += em * size; + known += 1; + } + } + if (known > 0 && expected > 0) { + const ratio = (right - x) / expected; + if (ratio < 0.6 || ratio > 1.7) { + // Wrong-glyph guess: discard + free, then re-emit in base-14. + removeAndDestroyObject(m, opts.page.pagePtr, ptr); + return emitBase14(); + } + } + } + return ptr; + }; + + // Try-charcodes wrapper: when we're reusing a source font AND the active + // charcode strategy can resolve EVERY char in the chunk. + function writeViaCharcodesOrSetText( + ptr: number, + text: string, + ): string | null { + const strategy = getActiveCharcodeStrategy(); + // The content-stream resolver is an untrusted sequential-CID GUESS. + if ( + strategy === "content-stream" && + !(!!opts.originalFontSubset && [...text].length === 1) + ) { + emitCharcodeEvent({ + timestamp: 0, + strategy, + text, + fontPtr: opts.originalFontPtr, + resolved: [], + missing: [...text], + note: "content-stream active but ungated (not subset+single-cp) - using SetText", + outcome: "partial-coverage-fallback", + }); + setTextOn(m, ptr, text); + return null; + } + if (!canReuse || !opts.originalFontPtr) { + emitCharcodeEvent({ + timestamp: 0, + strategy, + text, + fontPtr: opts.originalFontPtr, + resolved: [], + missing: [...text], + note: !canReuse + ? "no source font available (Helvetica fresh emit)" + : "originalFontPtr is 0", + outcome: "no-font", + }); + setTextOn(m, ptr, text); + return null; + } + // allowContentStreamFallback: if the active resolver misses, reuse the + // on-page glyph via the client-side content-stream resolver. + const allowGuessFallback = + !!opts.originalFontSubset && [...text].length === 1; + const resolved = tryResolveCharcodes( + opts.originalFontPtr, + text, + { + module: m, + pagePtr: opts.page.pagePtr, + docPtr: opts.doc.docPtr, + }, + allowGuessFallback, + ); + if (!resolved) { + emitCharcodeEvent({ + timestamp: 0, + strategy, + text, + fontPtr: opts.originalFontPtr, + resolved: [], + missing: [...text], + note: "active strategy is 'helvetica' (no resolver)", + outcome: "no-strategy", + }); + setTextOn(m, ptr, text); + return null; + } + const r = resolved.result; + // Code points, not UTF-16 units: the resolver counts per code point, + // so an astral char (emoji, CJK Ext-B) never matched text.length. + const cpLen = [...text].length; + if (r && r.coverage === cpLen && r.charcodes.length === cpLen) { + const ok = setCharcodesOn(m, ptr, r.charcodes); + emitCharcodeEvent({ + timestamp: 0, + strategy: resolved.strategy, + text, + fontPtr: opts.originalFontPtr, + resolved: [...r.charcodes], + missing: [], + note: r.note, + outcome: ok ? "charcodes-ok" : "charcodes-call-failed", + }); + if (ok) return resolved.strategy; + // SetCharcodes binding rejected the call - fall back. + } else if (r) { + emitCharcodeEvent({ + timestamp: 0, + strategy: resolved.strategy, + text, + fontPtr: opts.originalFontPtr, + resolved: [...r.charcodes], + missing: [...r.missing], + note: r.note, + outcome: "partial-coverage-fallback", + }); + } else { + emitCharcodeEvent({ + timestamp: 0, + strategy: resolved.strategy, + text, + fontPtr: opts.originalFontPtr, + resolved: [], + missing: [...text], + note: "resolver returned null (unavailable for this font)", + outcome: "partial-coverage-fallback", + }); + } + setTextOn(m, ptr, text); + return null; + } + + // Per-char emit branch for the BACKEND strategy. + const isBackendStrategy = getActiveCharcodeStrategy() === "backend"; + const hasAnyWhitespaceForBranch = /\s/.test(opts.text); + if ( + isBackendStrategy && + !hasAnyWhitespaceForBranch && + opts.text.length > 0 && + m2.FPDFPageObj_CreateTextObj + ) { + const ctx = { + module: m, + pagePtr: opts.page.pagePtr, + docPtr: opts.doc.docPtr, + }; + // Probe per char first. + const perChar: Array<{ ch: string; font: number; charcodes: number[] }> = + []; + let allOk = true; + for (const ch of opts.text) { + // Prefer the run's OWN font when it renders this char: it is the + // authoritative font for the run's text. + let charFont = 0; + let resolved = null; + if (opts.originalFontPtr) { + const own = tryResolveCharcodes(opts.originalFontPtr, ch, ctx); + if ( + own?.result && + own.result.charcodes.length === 1 && + own.result.missing.length === 0 + ) { + charFont = opts.originalFontPtr; + resolved = own; + } + } + if (!charFont) { + // Constrained to the run's own weight/slant: an unconstrained borrow + // takes the first matching glyph in content order, which is usually a + // bold heading, and the edited body text comes back bold. + charFont = + findFontForChar( + ch, + ctx, + opts.originalFontPtr, + styleClassFromName(family), + ) || 0; + if (!charFont) { + allOk = false; + break; + } + resolved = tryResolveCharcodes(charFont, ch, ctx); + } + if ( + !resolved?.result || + resolved.result.charcodes.length !== 1 || + resolved.result.missing.length > 0 + ) { + allOk = false; + break; + } + // A Type 3 face has no font program, so PDFium can report neither a + // glyph advance nor a usable ink box for it: the only trustworthy + // advance is one measured from the glyph as the page already draws it. + // Without that, each following glyph lands on top of this one - the + // reported scramble. Substitute a real face instead. + if ( + !fontIsReusable(m, charFont) && + onPageAdvanceEm(m, opts.page.pagePtr, charFont, ch) == null + ) { + allOk = false; + break; + } + perChar.push({ + ch, + font: charFont, + charcodes: resolved.result.charcodes, + }); + } + if (allOk && perChar.length === [...opts.text].length) { + // Per-char emit: one text object per char, each with its OWN font. + const ptrs: number[] = []; + let cursor = opts.x; + for (const pc of perChar) { + const ptr = m2.FPDFPageObj_CreateTextObj!( + opts.doc.docPtr, + pc.font, + size, + ); + if (!ptr) { + // CreateTextObj failed mid-word. + for (const p of ptrs) { + perCharBranchPtrs.delete(p); + removeAndDestroyObject(m, opts.page.pagePtr, p); + } + ptrs.length = 0; + break; + } + const ok = setCharcodesOn(m, ptr, pc.charcodes); + if (!ok) { + // Couldn't set charcodes - rare but possible. + removeAndDestroyObject(m, opts.page.pagePtr, ptr); + for (const p of ptrs) { + perCharBranchPtrs.delete(p); + removeAndDestroyObject(m, opts.page.pagePtr, p); + } + ptrs.length = 0; + break; + } + applyFillAndPos(m, opts.page, ptr, opts.fill, cursor, opts.y); + // Advance by the char's REAL on-page advance width, read from the same + // font+char already on the page. + const advEm = onPageAdvanceEm(m, opts.page.pagePtr, pc.font, pc.ch); + if (advEm != null) { + cursor += advEm * size; + } else { + // Unmeasurable: step by the font metric rather than the object's ink + // box. The ink box collapses on faces PDFium can't measure (stacking + // the glyphs) and overshoots on wide ones (visible gaps mid-word); + // a metric advance is even and always moves forward. + cursor += measureAdvancePt(pc.ch, family, size); + } + // Reproduce the source run's letter-spacing: the glyph advance above is + // the font's natural width. + cursor += opts.charSpacingPt ?? 0; + emitCharcodeEvent({ + timestamp: 0, + strategy: "backend", + text: pc.ch, + fontPtr: pc.font, + resolved: [...pc.charcodes], + missing: [], + note: `per-char backend emit: font=${pc.font} charcode=${pc.charcodes[0]}`, + outcome: "charcodes-ok", + }); + ptrs.push(ptr); + opts.outTexts?.push(pc.ch); + // Mark this ptr as verified - it was created via the per-char branch + // with a known-good pair from the backend resolver cache. + perCharBranchPtrs.add(ptr); + } + if (ptrs.length === [...opts.text].length) return withRotation(ptrs); + // Any other incomplete outcome: destroy the partial emit before the + // fall-through path re-renders the word. + for (const p of ptrs) { + perCharBranchPtrs.delete(p); + removeAndDestroyObject(m, opts.page.pagePtr, p); + } + if (opts.outTexts) opts.outTexts.length = 0; + } + // fall through to the normal path if per-char attempt didn't work + } + + // Letter-spaced runs: a single text object cannot carry Tc. + const hasAnyWhitespace = /\s/.test(opts.text); + const spacingPt = opts.charSpacingPt ?? 0; + if ( + !hasAnyWhitespace && + Math.abs(spacingPt) > 0.05 && + [...opts.text].length > 1 + ) { + const ptrs: number[] = []; + let cursor = opts.x; + for (const ch of opts.text) { + const ptr = emitWord(ch, cursor); + if (ptr) { + ptrs.push(ptr); + opts.outTexts?.push(ch); + } + // Advance by the char's true advance width: the on-page advance of the + // same char+font when it is still measurable, else canvas font metrics. + const advEm = opts.originalFontPtr + ? onPageAdvanceEm(m, opts.page.pagePtr, opts.originalFontPtr, ch) + : null; + cursor += + (advEm != null ? advEm * size : measureAdvancePt(ch, family, size)) + + spacingPt; + } + return withRotation(ptrs); + } + + // Fast path: no whitespace at all → one text object holds the whole word. + if (!hasAnyWhitespace) { + const ptr = emitWord(opts.text, opts.x); + if (ptr) opts.outTexts?.push(opts.text); + return withRotation(ptr ? [ptr] : []); + } + + // Per-chunk emit (split on ANY whitespace run). + const chunks = splitIntoWordChunks(opts.text, family, size) as WordChunk[] & { + leadingGapPt?: number; + leadingGapChars?: number; + }; + const spacing = opts.charSpacingPt ?? 0; + const ptrs: number[] = []; + let cursor = + opts.x + + (chunks.leadingGapPt ?? 0) + + spacing * (chunks.leadingGapChars ?? 0); + for (const chunk of chunks) { + if (chunk.text.length > 0) { + // Recurse per word. + const chunkTexts: string[] = []; + const wordPtrs = emitTextLine({ + ...opts, + text: chunk.text, + x: cursor, + rotation: undefined, + outTexts: opts.outTexts ? chunkTexts : undefined, + }); + if (wordPtrs.length === 0) continue; + if (opts.outTexts) opts.outTexts.push(...chunkTexts); + let rightEdge = 0; + for (const p of wordPtrs) + rightEdge = Math.max(rightEdge, measureObjRightEdgePt(m, p)); + // Only trust the measured edge when it advanced by a believable amount: + // a face PDFium can't measure reports a near-zero ink box and would put + // the next word on top of this one. + const metric = measureAdvancePt(chunk.text, family, size); + const advanced = rightEdge > cursor ? rightEdge - cursor : 0; + cursor += advanced >= metric * 0.35 ? advanced : metric; + ptrs.push(...wordPtrs); + } + // Word gaps stretch with the run's letter-spacing too: the source layout + // applies Tc after the glyph preceding the gap AND after each space. + cursor += + chunk.gapAfterPt + + (chunk.gapCharCount > 0 ? spacing * (chunk.gapCharCount + 1) : 0); + } + return withRotation(ptrs); +} + +interface TextObjReadModule { + FPDFText_LoadPage?: (page: number) => number; + FPDFText_ClosePage?: (tp: number) => void; + FPDFTextObj_GetText?: ( + obj: number, + tp: number, + buf: number, + len: number, + ) => number; +} + +// Decode a just-inserted text object's content through the font's ToUnicode +// (what any PDF reader will see), or null when unavailable. +// Read what several objects actually carry, through ONE text page. Callers that +// need to map emitted pointers back onto their source string must not assume a +// chunking: emitTextLine may produce one object per word, per char, or one for +// the whole string depending on which branch rendered it. +export function readObjTexts( + m: WrappedPdfiumModule, + pagePtr: number, + objPtrs: number[], +): Array { + const mod = m as unknown as TextObjReadModule; + const out: Array = objPtrs.map(() => null); + if ( + !mod.FPDFText_LoadPage || + !mod.FPDFTextObj_GetText || + !mod.FPDFText_ClosePage + ) { + return out; + } + const tp = mod.FPDFText_LoadPage(pagePtr); + if (!tp) return out; + try { + for (let i = 0; i < objPtrs.length; i += 1) { + const objPtr = objPtrs[i]; + if (!objPtr) continue; + try { + const len = mod.FPDFTextObj_GetText(objPtr, tp, 0, 0); + if (len <= 2) { + out[i] = ""; + continue; + } + const buf = m.pdfium.wasmExports.malloc(len); + try { + mod.FPDFTextObj_GetText(objPtr, tp, buf, len); + out[i] = readUtf16(m, buf, len); + } finally { + m.pdfium.wasmExports.free(buf); + } + } catch { + out[i] = null; + } + } + } finally { + try { + mod.FPDFText_ClosePage(tp); + } catch { + /* best-effort */ + } + } + return out; +} + +function readBackTextObj( + m: WrappedPdfiumModule, + pagePtr: number, + objPtr: number, +): string | null { + const mod = m as unknown as TextObjReadModule; + if ( + !mod.FPDFText_LoadPage || + !mod.FPDFTextObj_GetText || + !mod.FPDFText_ClosePage + ) { + return null; + } + const tp = mod.FPDFText_LoadPage(pagePtr); + if (!tp) return null; + try { + const len = mod.FPDFTextObj_GetText(objPtr, tp, 0, 0); + if (len <= 2) return ""; + const buf = m.pdfium.wasmExports.malloc(len); + try { + mod.FPDFTextObj_GetText(objPtr, tp, buf, len); + return readUtf16(m, buf, len); + } finally { + m.pdfium.wasmExports.free(buf); + } + } catch { + return null; + } finally { + try { + mod.FPDFText_ClosePage(tp); + } catch { + /* best-effort */ + } + } +} + +export function measureObjRightEdgePt( + m: WrappedPdfiumModule, + objPtr: number, +): number { + const l = m.pdfium.wasmExports.malloc(4); + const b = m.pdfium.wasmExports.malloc(4); + const r = m.pdfium.wasmExports.malloc(4); + const t = m.pdfium.wasmExports.malloc(4); + try { + if (!m.FPDFPageObj_GetBounds(objPtr, l, b, r, t)) return 0; + return m.pdfium.getValue(r, "float"); + } finally { + m.pdfium.wasmExports.free(l); + m.pdfium.wasmExports.free(b); + m.pdfium.wasmExports.free(r); + m.pdfium.wasmExports.free(t); + } +} + +/** + * Horizontal span covered by `ptrs`, or null when nothing is measurable. + * + * A fresh overlay emit replaces every object a run owns, so the run's old + * bounds describe geometry that no longer exists - a stale box leaves the + * editable overlay the wrong size over correctly drawn text. + */ +export function measureObjSpanPt( + m: WrappedPdfiumModule, + ptrs: number[], +): { left: number; right: number } | null { + const l = m.pdfium.wasmExports.malloc(4); + const b = m.pdfium.wasmExports.malloc(4); + const r = m.pdfium.wasmExports.malloc(4); + const t = m.pdfium.wasmExports.malloc(4); + try { + let left = Infinity; + let right = -Infinity; + for (const ptr of ptrs) { + if (!ptr) continue; + try { + if (!m.FPDFPageObj_GetBounds(ptr, l, b, r, t)) continue; + } catch { + continue; + } + const lo = m.pdfium.getValue(l, "float"); + const hi = m.pdfium.getValue(r, "float"); + if (!Number.isFinite(lo) || !Number.isFinite(hi)) continue; + if (lo < left) left = lo; + if (hi > right) right = hi; + } + return right > left ? { left, right } : null; + } finally { + m.pdfium.wasmExports.free(l); + m.pdfium.wasmExports.free(b); + m.pdfium.wasmExports.free(r); + m.pdfium.wasmExports.free(t); + } +} + +function setTextOn(m: WrappedPdfiumModule, ptr: number, text: string): void { + const textPtr = writeUtf16(m, text); + try { + m.FPDFText_SetText(ptr, textPtr); + } finally { + m.pdfium.wasmExports.free(textPtr); + } +} + +interface InkState { + renderMode?: number; + stroke?: RGBA | null; + strokeWidth?: number; +} + +interface InkModule { + FPDFTextObj_SetTextRenderMode?: (obj: number, mode: number) => boolean; + FPDFPageObj_SetStrokeColor?: ( + obj: number, + r: number, + g: number, + b: number, + a: number, + ) => boolean; + FPDFPageObj_SetStrokeWidth?: (obj: number, width: number) => boolean; +} + +/** Pen origin for one output line, in raw PDF page space. */ +export interface LineOrigin { + x: number; + y: number; +} + +// THE one place that decides where each re-emitted line's pen starts. Reuse the +// run's existing per-line origins when the line count still matches (so an edit +// keeps the source's exact baselines), otherwise step along the run's rotated +// down-axis: the (0,-lineHeight) vector through [cos,-sin] gives (sin*L,-cos*L). +export function planLineOrigins( + run: TextRun, + lineCount: number, + lineHeight: number, +): LineOrigin[] { + const rot = rotationFromMatrix(run.matrix); + const dcos = rot ? rot.cos : 1; + const dsin = rot ? rot.sin : 0; + const slots = run.paragraphLineSlots; + // Line i keeps slot i whenever that slot exists, even when the edit changed + // the line COUNT: an edit that drops a line must not move the lines above it. + const last = slots.length > 0 ? slots[slots.length - 1] : null; + // Past the last known slot, keep the paragraph's own leading. Restarting the + // ladder at run.matrix instead drops the surviving lines onto the text below. + const leading = paragraphLeading(slots) || lineHeight; + const out: LineOrigin[] = []; + for (let i = 0; i < lineCount; i++) { + const slot = slots[i]; + if (slot) { + out.push({ x: slot.matrixE, y: slot.baselineY }); + continue; + } + const step = last ? i - (slots.length - 1) : i; + const baseX = last ? last.matrixE : run.matrix.e; + const baseY = last ? last.baselineY : run.matrix.f; + out.push({ + x: baseX + step * leading * dsin, + y: baseY - step * leading * dcos, + }); + } + return out; +} + +/** Distance between consecutive line origins, robust under rotation. */ +function paragraphLeading(slots: ParagraphLineSlot[]): number { + if (slots.length < 2) return 0; + const a = slots[slots.length - 2]; + const b = slots[slots.length - 1]; + return Math.hypot(b.matrixE - a.matrixE, b.baselineY - a.baselineY); +} + +/** One re-emitted line: the objects created for it and where they landed. */ +export interface EmittedLine { + ptrs: number[]; + text: string; + /** Text of each ptr, parallel to `ptrs`. Callers must not re-derive this: + * emitTextLine emits per word OR per character, and guessing drops ptrs. */ + texts: string[]; + x: number; + y: number; +} + +// THE one place a whole run is re-emitted line by line. Rotation, ink state and +// per-line baselines are applied here so no caller can carry one and drop +// another - that fragmentation is why the same class of bug kept recurring. +export function emitRunLines(opts: { + doc: EditorDocument; + page: Page; + run: TextRun; + lines: string[]; + origins: LineOrigin[]; + originalFontPtr: number; + fallbackFamily: string; + originalFontSubset?: boolean; +}): EmittedLine[] { + const rot = rotationFromMatrix(opts.run.matrix); + const out: EmittedLine[] = []; + for (let i = 0; i < opts.lines.length; i++) { + const text = opts.lines[i]; + const origin = opts.origins[i]; + if (!origin) continue; + if (text.length === 0) { + out.push({ ptrs: [], text: "", texts: [], x: origin.x, y: origin.y }); + continue; + } + const texts: string[] = []; + const ptrs = emitTextLine({ + outTexts: texts, + doc: opts.doc, + page: opts.page, + text, + x: origin.x, + y: origin.y, + fontSize: opts.run.fontSize, + fill: opts.run.fill, + ...inkFromRun(opts.run), + originalFontPtr: opts.originalFontPtr, + originalFontSubset: opts.originalFontSubset, + charSpacingPt: opts.run.charSpacingPt, + fallbackFamily: opts.fallbackFamily, + // Keep the run's rotation on re-emit (no-op for upright text). + rotation: rot, + }); + out.push({ ptrs, text, texts, x: origin.x, y: origin.y }); + } + return out; +} + +// How a run's glyphs are painted, other than the fill. Spread as a unit so a +// call site cannot carry the render mode and forget the outline. +export function inkFromRun(run: { + renderMode?: number; + stroke?: RGBA | null; + strokeWidth?: number; +}): InkState { + return { + renderMode: run.renderMode, + stroke: run.stroke ?? null, + strokeWidth: run.strokeWidth, + }; +} + +/** Re-apply render mode and outline to freshly created text objects. */ +export function applyInkState( + m: WrappedPdfiumModule, + ptrs: number[], + ink: InkState, +): void { + const mod = m as unknown as InkModule; + const mode = ink.renderMode ?? 0; + const stroke = ink.stroke ?? null; + const width = ink.strokeWidth ?? 0; + for (const p of ptrs) { + if (!p) continue; + try { + // Written unconditionally: skipping mode 0 means nothing could ever put + // an object back to fill-only, so undoing an outline left it stroked. + mod.FPDFTextObj_SetTextRenderMode?.(p, mode); + if (stroke) { + mod.FPDFPageObj_SetStrokeColor?.( + p, + stroke.r, + stroke.g, + stroke.b, + stroke.a, + ); + mod.FPDFPageObj_SetStrokeWidth?.(p, width); + } else { + // A transparent zero-width stroke is how "no outline" is expressed. + mod.FPDFPageObj_SetStrokeWidth?.(p, 0); + mod.FPDFPageObj_SetStrokeColor?.(p, 0, 0, 0, 0); + } + } catch { + /* best-effort */ + } + } +} + +function applyFillAndPos( + m: WrappedPdfiumModule, + page: Page, + ptr: number, + fill: { r: number; g: number; b: number; a: number }, + x: number, + y: number, +): void { + m.FPDFPageObj_SetFillColor(ptr, fill.r, fill.g, fill.b, fill.a); + m.FPDFPageObj_Transform(ptr, 1, 0, 0, 1, x, y); + m.FPDFPage_InsertObject(page.pagePtr, ptr); +} + +/** Insert a filled rectangle (cover/background) and return its pointer. */ +export function emitFillRect( + m: WrappedPdfiumModule, + page: Page, + bounds: { x: number; y: number; width: number; height: number }, + fill: { r: number; g: number; b: number }, + margin = 1.5, +): number { + const ptr = m.FPDFPageObj_CreateNewRect( + bounds.x - margin, + bounds.y - margin, + bounds.width + margin * 2, + bounds.height + margin * 2, + ); + if (!ptr) return 0; + m.FPDFPageObj_SetFillColor(ptr, fill.r, fill.g, fill.b, 255); + m.FPDFPath_SetDrawMode(ptr, 2, false); + m.FPDFPage_InsertObject(page.pagePtr, ptr); + return ptr; +} diff --git a/frontend/editor/src/core/tools/pdfTextEditor/commands/partialEdit.ts b/frontend/editor/src/core/tools/pdfTextEditor/commands/partialEdit.ts new file mode 100644 index 0000000000..9fa46d4751 --- /dev/null +++ b/frontend/editor/src/core/tools/pdfTextEditor/commands/partialEdit.ts @@ -0,0 +1,1460 @@ +import type { EditorDocument } from "@app/tools/pdfTextEditor/model/EditorDocument"; +import type { Page } from "@app/tools/pdfTextEditor/model/Page"; +import type { + ParagraphLineSlot, + TextRun, +} from "@app/tools/pdfTextEditor/model/TextRun"; +import { + cssFontSpecFor, + emitTextLine, + inkFromRun, + isVerifiedPerCharPtr, + measureObjRightEdgePt, +} from "@app/tools/pdfTextEditor/commands/editTextHelpers"; +import { + fallbackFamilyFor, + fallbackFontIdFor, +} from "@app/tools/pdfTextEditor/util/fontCapability"; +import { writeUtf16 } from "@app/services/pdfiumService"; +import { transformObject } from "@app/tools/pdfTextEditor/util/objectTransform"; + +/** Set the text of an EXISTING PDFium text object, preserving its font. */ +export function setObjText( + m: import("@embedpdf/pdfium").WrappedPdfiumModule, + ptr: number, + text: string, +): void { + if (!ptr) return; + const buf = writeUtf16(m, text); + try { + m.FPDFText_SetText(ptr, buf); + } catch { + /* best-effort */ + } finally { + m.pdfium.wasmExports.free(buf); + } +} + +/** Read a text object's left/right edge in page points. */ +function objBoundsLR( + m: import("@embedpdf/pdfium").WrappedPdfiumModule, + ptr: number, + fallbackX: number, +): { x: number; right: number } { + const l = m.pdfium.wasmExports.malloc(4); + const b = m.pdfium.wasmExports.malloc(4); + const r = m.pdfium.wasmExports.malloc(4); + const t = m.pdfium.wasmExports.malloc(4); + try { + if (!m.FPDFPageObj_GetBounds(ptr, l, b, r, t)) { + return { x: fallbackX, right: fallbackX }; + } + return { + x: m.pdfium.getValue(l, "float"), + right: m.pdfium.getValue(r, "float"), + }; + } finally { + m.pdfium.wasmExports.free(l); + m.pdfium.wasmExports.free(b); + m.pdfium.wasmExports.free(r); + m.pdfium.wasmExports.free(t); + } +} + +// Map freshly-emitted line objects back to the text they carry, building the +// slot's mergedFrom* arrays. `emitted` is emitTextLine's own record of what +// each ptr holds - it emits per word OR per character, so deriving it from the +// text mislabels every ptr past the word count. +function buildSlotMerged( + m: import("@embedpdf/pdfium").WrappedPdfiumModule, + ptrs: number[], + text: string, + leftX: number, + emitted?: string[], +): { + ptrs: number[]; + texts: string[]; + bounds: Array<{ x: number; right: number }>; + charStarts: number[]; +} { + const outPtrs: number[] = []; + const texts: string[] = []; + const bounds: Array<{ x: number; right: number }> = []; + const charStarts: number[] = []; + const words: Array<{ text: string; start: number }> = []; + if (emitted && emitted.length === ptrs.length) { + let at = 0; + for (const piece of emitted) { + const found = text.indexOf(piece, at); + const start = found >= 0 ? found : at; + words.push({ text: piece, start }); + at = start + piece.length; + } + } else { + const re = /\S+/g; + let wm: RegExpExecArray | null; + while ((wm = re.exec(text)) !== null) { + words.push({ text: wm[0], start: wm.index }); + } + } + for (let i = 0; i < ptrs.length; i++) { + const w = words[i]; + const b = objBoundsLR(m, ptrs[i], leftX); + outPtrs.push(ptrs[i]); + texts.push(w ? w.text : ""); + bounds.push({ x: b.x, right: b.right }); + charStarts.push(w ? w.start : text.length); + } + return { ptrs: outPtrs, texts, bounds, charStarts }; +} + +// Astral characters (emoji, math symbols, CJK ext-B) are two UTF-16 code units. +// The planners index by code UNIT, so a boundary landing between the halves +// would emit a lone surrogate. The helpers below let the planners bail only on +// the edits that actually cut a pair, instead of on any text containing one. +const HI_MIN = 0xd800; +const HI_MAX = 0xdbff; +const LO_MIN = 0xdc00; +const LO_MAX = 0xdfff; + +/** Any surrogate code unit at all - BMP-only text skips every check below. */ +function hasAnySurrogate(s: string): boolean { + for (let i = 0; i < s.length; i++) { + const u = s.charCodeAt(i); + if (u >= HI_MIN && u <= LO_MAX) return true; + } + return false; +} + +/** No orphaned half: every high surrogate is followed by its low. */ +function isWellFormedUtf16(s: string): boolean { + for (let i = 0; i < s.length; i++) { + const u = s.charCodeAt(i); + if (u >= HI_MIN && u <= HI_MAX) { + const next = i + 1 < s.length ? s.charCodeAt(i + 1) : 0; + if (next < LO_MIN || next > LO_MAX) return false; + i++; + continue; + } + if (u >= LO_MIN && u <= LO_MAX) return false; + } + return true; +} + +/** True when slicing `s` at code-unit `idx` would not cut a surrogate pair. */ +function isCodePointBoundary(s: string, idx: number): boolean { + if (idx <= 0 || idx >= s.length) return true; + const before = s.charCodeAt(idx - 1); + const at = s.charCodeAt(idx); + return !( + before >= HI_MIN && + before <= HI_MAX && + at >= LO_MIN && + at <= LO_MAX + ); +} + +/** Push a slice end off the middle of a pair so no half is orphaned. */ +function toCodePointBoundary(s: string, idx: number): number { + return isCodePointBoundary(s, idx) ? idx : idx + 1; +} + +// Both halves of every astral char must share the SAME fate in the diff, and a +// kept pair must stay adjacent on the other side. Sibling emoji share a high +// surrogate (U+1F600 and U+1F601 are both \uD83D...), so the code-unit LCS can +// match the highs and drop the lows - exactly the case this rejects. +function surrogatePairsSurviveTogether( + prev: string, + next: string, + keptA: Set, + keptB: Set, + alignment: Array<{ aIdx: number; bIdx: number }>, +): boolean { + const aToB = new Map(); + const bToA = new Map(); + for (const { aIdx, bIdx } of alignment) { + aToB.set(aIdx, bIdx); + bToA.set(bIdx, aIdx); + } + for (let a = 0; a + 1 < prev.length; a++) { + const hi = prev.charCodeAt(a); + if (hi < HI_MIN || hi > HI_MAX) continue; + const lo = prev.charCodeAt(a + 1); + if (lo < LO_MIN || lo > LO_MAX) continue; + if (keptA.has(a) !== keptA.has(a + 1)) return false; + if (keptA.has(a) && aToB.get(a + 1) !== (aToB.get(a) ?? -2) + 1) + return false; + a++; + } + for (let b = 0; b + 1 < next.length; b++) { + const hi = next.charCodeAt(b); + if (hi < HI_MIN || hi > HI_MAX) continue; + const lo = next.charCodeAt(b + 1); + if (lo < LO_MIN || lo > LO_MAX) continue; + if (keptB.has(b) !== keptB.has(b + 1)) return false; + if (keptB.has(b) && bToA.get(b + 1) !== (bToA.get(b) ?? -2) + 1) + return false; + b++; + } + return true; +} + +/** Diff-driven partial editing. */ +export interface PartialEditOp { + type: "keep" | "insert" | "modify"; + /** keep / modify: sub-run index in run.mergedFromPtrs */ + subRunIdx?: number; + /** insert: text to emit in fallback font. modify: surviving chars to + * SetText onto the existing object (keeps its embedded font). */ + text?: string; + // insert only: the original sub-run this insert is replacing (came from a + // "mixed" sub-run whose kept chars need a new emit). + anchorSubRunIdx?: number; + /** insert only: the FOLLOWING kept sub-run this insert is a prefix of. */ + anchorBeforeSubRunIdx?: number; + // insert only: how many whitespace chars in nextText sit between the previous + // emitted glyph and this insert but belong to NO sub-run. + leadingGhostCount?: number; + /** Position in nextText where this op's first char lives. */ + startBIdx: number; +} + +export interface PartialEditPlan { + removePtrs: Array<{ ptr: number; containerPtr: number }>; + ops: PartialEditOp[]; + /** Per-sub-run status (parallel to prevMergedFromPtrs). */ + subRunStatus: Array<"all-kept" | "all-deleted" | "mixed">; + /** Snapshot of current model arrays for revert. */ + prevMergedFromPtrs: number[]; + prevMergedFromTexts: string[]; + prevMergedFromBounds: Array<{ x: number; right: number }>; +} + +function lcsIndices( + a: string, + b: string, +): { + keptA: Set; + keptB: Set; + alignment: Array<{ aIdx: number; bIdx: number }>; +} { + const m = a.length; + const n = b.length; + const dp: Int32Array[] = new Array(m + 1); + for (let i = 0; i <= m; i++) dp[i] = new Int32Array(n + 1); + for (let i = 1; i <= m; i++) { + for (let j = 1; j <= n; j++) { + if (a[i - 1] === b[j - 1]) dp[i][j] = dp[i - 1][j - 1] + 1; + else + dp[i][j] = dp[i - 1][j] >= dp[i][j - 1] ? dp[i - 1][j] : dp[i][j - 1]; + } + } + const keptA = new Set(); + const keptB = new Set(); + const alignment: Array<{ aIdx: number; bIdx: number }> = []; + let i = m; + let j = n; + while (i > 0 && j > 0) { + if (a[i - 1] === b[j - 1]) { + keptA.add(i - 1); + keptB.add(j - 1); + alignment.unshift({ aIdx: i - 1, bIdx: j - 1 }); + i--; + j--; + } else if (dp[i - 1][j] >= dp[i][j - 1]) { + i--; + } else { + j--; + } + } + return { keptA, keptB, alignment }; +} + +export function planPartialEdit( + run: TextRun, + prevText: string, + nextText: string, +): PartialEditPlan | null { + if (run.mergedFromPtrs.length === 0) return null; + if (run.mergedFromTexts.length !== run.mergedFromPtrs.length) return null; + if (run.mergedFromBounds.length !== run.mergedFromPtrs.length) return null; + if (nextText.length === 0) return null; + if (prevText === nextText) return null; + // Astral text is diffed in code UNITS. Rather than refusing every run that + // holds a pair, refuse only the edits that would cut one (checked below). + const astral = hasAnySurrogate(prevText) || hasAnySurrogate(nextText); + if ( + astral && + (!isWellFormedUtf16(prevText) || !isWellFormedUtf16(nextText)) + ) { + return null; + } + + let { keptA, keptB, alignment } = lcsIndices(prevText, nextText); + + // Pure append (nextText starts with prevText): force the trivial 1:1 prefix + // alignment. + if (nextText.startsWith(prevText)) { + keptA = new Set(); + keptB = new Set(); + alignment = []; + for (let i = 0; i < prevText.length; i++) { + keptA.add(i); + keptB.add(i); + alignment.push({ aIdx: i, bIdx: i }); + } + } + + // Only now, against the alignment the ops walk will actually use: a diff + // boundary landing inside an astral char would emit a lone surrogate. + if ( + astral && + !surrogatePairsSurviveTogether(prevText, nextText, keptA, keptB, alignment) + ) { + return null; + } + + // Read per-sub-run char-start positions directly off the run. + if ( + run.mergedFromCharStarts.length !== run.mergedFromPtrs.length || + run.mergedFromCharStarts.some((s) => s < 0 || s > prevText.length) + ) { + // Stale or missing char-starts (e.g. an overlay-path edit cleared + // the ptrs without also setting char-starts). Bail safely. + return null; + } + const charToSubRun = new Array(prevText.length).fill(-1); + const subRunRanges: Array<{ start: number; end: number } | null> = []; + for (let i = 0; i < run.mergedFromTexts.length; i++) { + const subText = run.mergedFromTexts[i]; + const start = run.mergedFromCharStarts[i]; + const end = start + subText.length; + if (subText.length === 0) { + subRunRanges.push({ start, end }); + continue; + } + if (end > prevText.length) return null; + // Sanity check: the stored chars must actually match prevText at + // that position. Catches model corruption without silent drift. + if (prevText.slice(start, end) !== subText) return null; + // A sub-run split mid-pair would make "modify" SetText half a char. + if ( + astral && + (!isCodePointBoundary(prevText, start) || + !isCodePointBoundary(prevText, end)) + ) { + return null; + } + for (let c = start; c < end; c++) { + charToSubRun[c] = i; + } + subRunRanges.push({ start, end }); + } + + // Classify sub-runs by counting how many of their own chars (the + // tracked range, not ghost gaps) survived the LCS. + const subRunStatus: Array<"all-kept" | "all-deleted" | "mixed"> = []; + const mixedSubRuns = new Set(); + // For each mixed sub-run, the surviving chars (in original order). + const mixedSurviving = new Map(); + for (let i = 0; i < run.mergedFromTexts.length; i++) { + const range = subRunRanges[i]; + if (!range) { + subRunStatus.push("all-kept"); + continue; + } + const subLen = range.end - range.start; + if (subLen === 0) { + subRunStatus.push("all-kept"); + continue; + } + let keptCount = 0; + let surviving = ""; + for (let c = range.start; c < range.end; c++) { + if (keptA.has(c)) { + keptCount += 1; + surviving += prevText[c]; + } + } + if (keptCount === 0) subRunStatus.push("all-deleted"); + else if (keptCount === subLen) subRunStatus.push("all-kept"); + else if (surviving.trim() === "") { + // Only whitespace survives this partially-deleted sub-run. + subRunStatus.push("all-deleted"); + } else { + subRunStatus.push("mixed"); + mixedSubRuns.add(i); + mixedSurviving.set(i, surviving); + } + } + + // Build ops by walking nextText. + const ops: PartialEditOp[] = []; + let lastSubRun = -1; + let insertBuf = ""; + let insertAnchorSubRun: number | undefined; + let insertStartBIdx = 0; + // bIdx of the last char that produced (or rode on) a glyph - i.e. a kept real + // char, a modified char, or an inserted char. + let lastEmittedBIdx = -1; + // Ghost whitespace chars sitting right before the pending insert. + let insertLeadingGhosts = 0; + // Mixed sub-runs we've already emitted a single "modify" op for, so a + // later surviving char from the same sub-run doesn't emit a second. + const modifiedSubRuns = new Set(); + function flushInsert(anchorBeforeSubRunIdx?: number): void { + if (insertBuf.length === 0) return; + ops.push({ + type: "insert", + text: insertBuf, + anchorSubRunIdx: insertAnchorSubRun, + anchorBeforeSubRunIdx, + leadingGhostCount: insertLeadingGhosts, + startBIdx: insertStartBIdx, + }); + insertBuf = ""; + insertAnchorSubRun = undefined; + insertLeadingGhosts = 0; + } + // Map next-bIdx → aIdx via alignment array + const bToA = new Map(); + for (const { aIdx, bIdx } of alignment) bToA.set(bIdx, aIdx); + + // INTERIOR-INSERT GUARD. Single-char sub-runs have no interior. + { + const keptMin = new Map(); + const keptMax = new Map(); + const keptCnt = new Map(); + for (const b of keptB) { + const a = bToA.get(b); + if (a === undefined) continue; + const sr = charToSubRun[a]; + if (sr < 0) continue; + keptMin.set(sr, Math.min(keptMin.get(sr) ?? b, b)); + keptMax.set(sr, Math.max(keptMax.get(sr) ?? b, b)); + keptCnt.set(sr, (keptCnt.get(sr) ?? 0) + 1); + } + for (const [sr, cnt] of keptCnt) { + if (keptMax.get(sr)! - keptMin.get(sr)! + 1 !== cnt) return null; + } + } + + for (let b = 0; b < nextText.length; b++) { + if (keptB.has(b)) { + const a = bToA.get(b)!; + const subRunIdx = charToSubRun[a]; + // Ghost char (LineGrouper-synthesised whitespace, not part of any PDFium + // text object). + if (subRunIdx === -1) continue; + // Whitespace-only survivor of a now-deleted sub-run: drop, never keep its ptr. + if (subRunStatus[subRunIdx] === "all-deleted") continue; + // Surviving chars of a mixed sub-run keep their ORIGINAL embedded font: + // we SetText the surviving substring back onto the existing. + if (mixedSubRuns.has(subRunIdx)) { + flushInsert(); + if (!modifiedSubRuns.has(subRunIdx)) { + ops.push({ + type: "modify", + subRunIdx, + text: mixedSurviving.get(subRunIdx) ?? "", + startBIdx: b, + }); + modifiedSubRuns.add(subRunIdx); + } + lastEmittedBIdx = b; + continue; + } + // A pending pure-insert that ends in a non-whitespace char, sits at the + // START of this NEW sub-run. + let anchorBeforeIdx: number | undefined; + if ( + insertBuf.length > 0 && + insertAnchorSubRun === undefined && + subRunIdx !== lastSubRun && + !/\s$/.test(insertBuf) && + (insertStartBIdx === 0 || /\s/.test(nextText[insertStartBIdx - 1])) + ) { + anchorBeforeIdx = subRunIdx; + } + flushInsert(anchorBeforeIdx); + if (subRunIdx !== lastSubRun) { + ops.push({ type: "keep", subRunIdx, startBIdx: b }); + lastSubRun = subRunIdx; + } + lastEmittedBIdx = b; + } else { + if (insertBuf.length === 0) { + insertStartBIdx = b; + // Whitespace chars skipped since the last real glyph are ghost + // spaces this insert must sit AFTER (not on top of). + insertLeadingGhosts = Math.max(0, b - lastEmittedBIdx - 1); + } + insertBuf += nextText[b]; + lastEmittedBIdx = b; + } + } + flushInsert(); + + // Collect removals: only ALL-deleted sub-runs. + const removePtrs: Array<{ ptr: number; containerPtr: number }> = []; + for (let i = 0; i < run.mergedFromPtrs.length; i++) { + if (subRunStatus[i] === "all-deleted") { + removePtrs.push({ + ptr: run.mergedFromPtrs[i], + containerPtr: run.containerPtr, + }); + } + } + + if (ops.length === 0) return null; + + return { + removePtrs, + ops, + subRunStatus, + prevMergedFromPtrs: [...run.mergedFromPtrs], + prevMergedFromTexts: [...run.mergedFromTexts], + prevMergedFromBounds: run.mergedFromBounds.map((b) => ({ ...b })), + }; +} + +let _wsMeasureCanvas: HTMLCanvasElement | null = null; +/** Canvas-measured advance width for whitespace chars. */ +function measureWhitespaceAdvancePt( + text: string, + fontFamily: string, + fontSizePt: number, +): number { + if (typeof document === "undefined") return text.length * fontSizePt * 0.27; + if (!_wsMeasureCanvas) _wsMeasureCanvas = document.createElement("canvas"); + const ctx = _wsMeasureCanvas.getContext("2d"); + if (!ctx) return text.length * fontSizePt * 0.27; + // px on purpose: an n-px font measured in px returns the same number as + // an n-pt font in pt; `${n}pt` would inflate the result by 4/3. + ctx.font = cssFontSpecFor(fontFamily, fontSizePt); + return ctx.measureText(text).width; +} + +interface FontReadingModule { + FPDFTextObj_GetFont?: (ptr: number) => number; +} + +// Borrow the font handle from the FIRST surviving sub-object that wasn't slated +// for removal. +function borrowFontFromSurvivor( + m: import("@embedpdf/pdfium").WrappedPdfiumModule, + plan: PartialEditPlan, +): number { + const fontMod = m as unknown as FontReadingModule; + if (!fontMod.FPDFTextObj_GetFont) return 0; + const removed = new Set(plan.removePtrs.map((r) => r.ptr)); + for (let i = 0; i < plan.prevMergedFromPtrs.length; i++) { + const ptr = plan.prevMergedFromPtrs[i]; + if (!ptr || removed.has(ptr)) continue; + try { + const fontPtr = fontMod.FPDFTextObj_GetFont(ptr); + if (fontPtr) return fontPtr; + } catch { + /* try next survivor */ + } + } + return 0; +} + +// Borrow the font of a surviving sub-object that ACTUALLY CONTAINS the +// characters we're about to insert. +function borrowFontForChars( + m: import("@embedpdf/pdfium").WrappedPdfiumModule, + plan: PartialEditPlan, + chars: string, +): number { + const fontMod = m as unknown as FontReadingModule; + if (!fontMod.FPDFTextObj_GetFont) return 0; + const removed = new Set(plan.removePtrs.map((r) => r.ptr)); + const want = new Set([...chars].filter((c) => c.trim().length > 0)); + if (want.size > 0) { + // Prefer a survivor whose text shares the most chars with the insert + // (so multi-char inserts pick a font covering as much as possible). + let bestPtr = 0; + let bestScore = 0; + for (let i = 0; i < plan.prevMergedFromPtrs.length; i++) { + const ptr = plan.prevMergedFromPtrs[i]; + if (!ptr || removed.has(ptr)) continue; + const text = plan.prevMergedFromTexts[i] ?? ""; + let score = 0; + for (const c of text) if (want.has(c)) score += 1; + if (score > bestScore) { + bestScore = score; + bestPtr = ptr; + } + } + if (bestPtr) { + try { + const fontPtr = fontMod.FPDFTextObj_GetFont(bestPtr); + if (fontPtr) return fontPtr; + } catch { + /* fall through */ + } + } + } + return borrowFontFromSurvivor(m, plan); +} + +interface FormRemovalModule { + FPDFFormObj_RemoveObject?: (form: number, obj: number) => boolean; +} + +export interface PartialEditApplyResult { + newMergedFromPtrs: number[]; + newMergedFromTexts: string[]; + newMergedFromBounds: Array<{ x: number; right: number }>; + /** Per-sub-run char-start positions in the NEW run.text (post-edit). */ + newMergedFromCharStarts: number[]; + insertedPtrs: number[]; + newBoundsX: number; + newBoundsWidth: number; +} + +export function applyPartialEditPlan( + doc: EditorDocument, + page: Page, + run: TextRun, + plan: PartialEditPlan, + /** Override the baseline used for emitted inserts. */ + baselineY?: number, + // Override the left edge used for the FIRST unanchored insert (before any + // keep op has set the cursor). + defaultX?: number, +): PartialEditApplyResult { + const m = doc.module; + const formMod = m as unknown as FormRemovalModule; + const emitY = baselineY ?? run.matrix.f; + const startX = defaultX ?? run.bounds.x; + // Removals run before the walk below: it re-emits from the surviving + // pointers, so a deleted object still on the page would be re-counted. + for (const { ptr, containerPtr } of plan.removePtrs) { + if (!ptr) continue; + if (containerPtr && formMod.FPDFFormObj_RemoveObject) { + try { + formMod.FPDFFormObj_RemoveObject(containerPtr, ptr); + } catch { + /* best-effort */ + } + } else { + try { + m.FPDFPage_RemoveObject(page.pagePtr, ptr); + } catch { + /* best-effort */ + } + } + } + + const fallbackFamily = fallbackFamilyFor(run.fontId); + const newMergedFromPtrs: number[] = []; + const newMergedFromTexts: string[] = []; + const newMergedFromBounds: Array<{ x: number; right: number }> = []; + const newMergedFromCharStarts: number[] = []; + const insertedPtrs: number[] = []; + + // Font-borrow strategy for inserted text: Embedded CID fonts have no reliable + // Unicode→CID reverse lookup (ToUnicode CMaps are one-way by design). + const survivingChars = new Set(); + for (let i = 0; i < plan.prevMergedFromTexts.length; i++) { + if (plan.subRunStatus[i] !== "all-deleted") { + for (const ch of plan.prevMergedFromTexts[i]) survivingChars.add(ch); + } + } + for (const otherPage of doc.loadedPages()) { + for (const otherRun of otherPage.runs) { + if (otherRun.fontId !== run.fontId) continue; + for (const ch of otherRun.text) survivingChars.add(ch); + for (const sub of otherRun.mergedFromTexts) { + for (const ch of sub) survivingChars.add(ch); + } + } + } + let allInsertCharsAreSafe = true; + for (const op of plan.ops) { + if (op.type === "insert" && op.text) { + for (const ch of op.text) { + if (!survivingChars.has(ch)) { + allInsertCharsAreSafe = false; + break; + } + } + } + if (!allInsertCharsAreSafe) break; + } + + // Strategy: walk ops in order. + let firstX = startX; + let lastEnd = startX; + let offset = 0; + // Tracks the highest sub-run index we've already accounted for in `offset`. + let processedUpTo = -1; + function absorbDeletesBefore(idx: number): void { + for (let i = processedUpTo + 1; i < idx; i++) { + if (plan.subRunStatus[i] === "all-deleted") { + const b = plan.prevMergedFromBounds[i]; + if (!b) continue; + // Subtract the deleted sub-run's ADVANCE, not just its ink width. + const next = plan.prevMergedFromBounds[i + 1]; + offset -= next && next.x > b.x ? next.x - b.x : b.right - b.x; + } + } + processedUpTo = Math.max(processedUpTo, idx); + } + + for (const op of plan.ops) { + if (op.type === "keep" && op.subRunIdx !== undefined) { + absorbDeletesBefore(op.subRunIdx); + const ptr = plan.prevMergedFromPtrs[op.subRunIdx]; + const text = plan.prevMergedFromTexts[op.subRunIdx]; + const origBounds = plan.prevMergedFromBounds[op.subRunIdx]; + if (Math.abs(offset) > 0.05) { + try { + transformObject(m, ptr, 1, 0, 0, 1, offset, 0); + } catch { + /* best-effort */ + } + } + const newX = origBounds.x + offset; + const newRight = origBounds.right + offset; + newMergedFromPtrs.push(ptr); + newMergedFromTexts.push(text); + newMergedFromBounds.push({ x: newX, right: newRight }); + newMergedFromCharStarts.push(op.startBIdx); + if (newRight > lastEnd) lastEnd = newRight; + } else if ( + op.type === "modify" && + op.subRunIdx !== undefined && + op.text !== undefined + ) { + // Edit a mixed sub-run's EXISTING object in place: SetText the surviving + // chars so the embedded font is kept. + absorbDeletesBefore(op.subRunIdx); + const ptr = plan.prevMergedFromPtrs[op.subRunIdx]; + const origBounds = plan.prevMergedFromBounds[op.subRunIdx]; + const origWidth = origBounds.right - origBounds.x; + const modText = op.text; + // Read the object's own font BEFORE we touch it, so a fallback re-emit + // can reuse the same embedded font via the charcode/backend path. + const modFontPtr = objFontPtr(m, ptr); + setObjText(m, ptr, modText); + if (Math.abs(offset) > 0.05) { + try { + transformObject(m, ptr, 1, 0, 0, 1, offset, 0); + } catch { + /* best-effort */ + } + } + const newX = origBounds.x + offset; + const measuredRight = measureObjRightEdgePt(m, ptr); + // Validate the in-place SetText the SAME way inserts are validated. + const modNonWs = modText.replace(/\s+/g, "").length; + const modMinExpected = modNonWs * run.fontSize * 0.15; + if (modNonWs > 0 && measuredRight - newX < modMinExpected) { + try { + m.FPDFPage_RemoveObject(page.pagePtr, ptr); + } catch { + /* best-effort */ + } + const reptrs = emitTextLine({ + doc, + page, + text: modText, + x: newX, + y: emitY, + fontSize: run.fontSize, + fill: run.fill, + ...inkFromRun(run), + originalFontPtr: modFontPtr, + originalFontSubset: run.fontSubset, + charSpacingPt: run.charSpacingPt, + fallbackFamily, + }); + let reRight = newX; + for (const rp of reptrs) { + const r = measureObjRightEdgePt(m, rp); + if (r > reRight) reRight = r; + } + if (reptrs.length === 0) { + // Nothing representable emitted - treat like a deletion: the sub-run's + // width collapses and following sub-runs shift left to close the gap. + offset -= origWidth; + } else { + // Slice modText across the re-emitted ptrs so each stored text is + // contiguous and the next edit's char-range sanity check still tiles. + const total = reRight - newX; + const per = Math.max(1, Math.floor(modText.length / reptrs.length)); + let cur = newX; + let charCursor = 0; + for (let i = 0; i < reptrs.length; i++) { + const isLast = i === reptrs.length - 1; + const slice = isLast + ? modText.slice(charCursor) + : modText.slice( + charCursor, + toCodePointBoundary(modText, charCursor + per), + ); + const w = total / reptrs.length; + newMergedFromPtrs.push(reptrs[i]); + newMergedFromTexts.push(slice); + newMergedFromBounds.push({ x: cur, right: cur + w }); + newMergedFromCharStarts.push(op.startBIdx + charCursor); + insertedPtrs.push(reptrs[i]); + cur += w; + charCursor += slice.length; + } + if (reRight > lastEnd) lastEnd = reRight; + offset += reRight - newX - origWidth; + } + } else { + const newRight = + measuredRight > newX ? measuredRight : newX + origWidth; + newMergedFromPtrs.push(ptr); + newMergedFromTexts.push(modText); + newMergedFromBounds.push({ x: newX, right: newRight }); + newMergedFromCharStarts.push(op.startBIdx); + if (newRight > lastEnd) lastEnd = newRight; + // Subsequent sub-runs shift by the width delta (surviving text is + // usually narrower than the original). + offset += newRight - newX - origWidth; + } + } else if (op.type === "insert" && op.text) { + const insertText = op.text; + const anchorIdx = op.anchorSubRunIdx; + const beforeIdx = op.anchorBeforeSubRunIdx; + if (anchorIdx !== undefined) absorbDeletesBefore(anchorIdx); + else if (beforeIdx !== undefined) absorbDeletesBefore(beforeIdx); + const origBounds = + anchorIdx !== undefined ? plan.prevMergedFromBounds[anchorIdx] : null; + // "prefix of the following word" anchor: emit at that kept sub-run's + // original left edge so the insert + the glyphs after it read as one. + const beforeBounds = + beforeIdx !== undefined ? plan.prevMergedFromBounds[beforeIdx] : null; + // Anchor priority: * anchorSubRunIdx: emit at the replaced sub-run's x. + const leadingGap = + (op.leadingGhostCount ?? 0) * Math.max(1, run.fontSize) * 0.25; + const anchorX = origBounds + ? origBounds.x + offset + : beforeBounds + ? beforeBounds.x + offset + : lastEnd + leadingGap; + + // Borrow the font from a survivor that actually contains the inserted + // chars, so the new glyph reuses that exact embedded font. + const borrowedFontPtr = allInsertCharsAreSafe + ? borrowFontForChars(m, plan, insertText) + : 0; + + // Try the borrowed source font first; measure the result and fall back to + // Helvetica if the rendered width is sub-threshold. + let ptrs = emitTextLine({ + doc, + page, + text: insertText, + x: anchorX, + y: emitY, + fontSize: run.fontSize, + fill: run.fill, + ...inkFromRun(run), + originalFontPtr: borrowedFontPtr, + originalFontSubset: run.fontSubset, + charSpacingPt: run.charSpacingPt, + fallbackFamily, + }); + let realRightEdge = anchorX; + for (const ptr of ptrs) { + const r = measureObjRightEdgePt(m, ptr); + if (r > realRightEdge) realRightEdge = r; + } + let measuredWidth = realRightEdge - anchorX; + + // Heuristic: a working visible glyph is at least ~0.15 * fontSize wide. + const nonWhitespaceLen = insertText.replace(/\s/g, "").length; + const minExpected = nonWhitespaceLen * run.fontSize * 0.15; + // Skip the tofu retry when ALL returned ptrs came from the per-char + // backend emit branch in emitTextLine. + const allVerified = + ptrs.length > 0 && ptrs.every((p) => isVerifiedPerCharPtr(p)); + if ( + !allVerified && + borrowedFontPtr !== 0 && + nonWhitespaceLen > 0 && + measuredWidth < minExpected + ) { + // Remove the failed text objects before retrying. + for (const ptr of ptrs) { + if (!ptr) continue; + try { + m.FPDFPage_RemoveObject(page.pagePtr, ptr); + } catch { + /* best-effort */ + } + } + ptrs = emitTextLine({ + doc, + page, + text: insertText, + x: anchorX, + y: emitY, + fontSize: run.fontSize, + fill: run.fill, + ...inkFromRun(run), + originalFontPtr: 0, + charSpacingPt: run.charSpacingPt, + fallbackFamily, + }); + realRightEdge = anchorX; + for (const ptr of ptrs) { + const r = measureObjRightEdgePt(m, ptr); + if (r > realRightEdge) realRightEdge = r; + } + measuredWidth = realRightEdge - anchorX; + } + // Add the advance width of whitespace chars so the offset that shifts + // following kept sub-runs accounts for inserted spaces. + const whitespaceLen = insertText.length - nonWhitespaceLen; + if (whitespaceLen > 0) { + const wsWidth = measureWhitespaceAdvancePt( + " ".repeat(whitespaceLen), + fallbackFamily, + run.fontSize, + ); + // Letter-spaced runs stretch inserted spaces too (Tc applies to + // space glyphs), matching the widened gaps emitTextLine produced. + measuredWidth += wsWidth + run.charSpacingPt * whitespaceLen; + } + // Map emitted ptrs back to text. emitTextLine emits one ptr per + // whitespace-separated WORD on the normal path. + const insertWords: Array<{ text: string; start: number }> = []; + { + const wordRe = /\S+/g; + let wm: RegExpExecArray | null; + while ((wm = wordRe.exec(insertText)) !== null) { + insertWords.push({ text: wm[0], start: wm.index }); + } + } + if (ptrs.length === insertWords.length) { + for (let i = 0; i < ptrs.length; i++) { + const word = insertWords[i]; + if (!word) { + try { + m.FPDFPage_RemoveObject(page.pagePtr, ptrs[i]); + } catch { + /* best-effort */ + } + continue; + } + const bnds = objBoundsLR(m, ptrs[i], anchorX); + newMergedFromPtrs.push(ptrs[i]); + newMergedFromTexts.push(word.text); + newMergedFromBounds.push({ x: bnds.x, right: bnds.right }); + newMergedFromCharStarts.push(op.startBIdx + word.start); + insertedPtrs.push(ptrs[i]); + } + } else { + // Per-char (or mismatched) emit: slice the insert text across ptrs. + let runningCursor = anchorX; + const charsPerPtr = Math.max( + 1, + Math.floor(insertText.length / Math.max(1, ptrs.length)), + ); + let charCursor = 0; + for (let i = 0; i < ptrs.length; i++) { + const sliceWidth = measuredWidth / ptrs.length; + const isLast = i === ptrs.length - 1; + const sliceText = isLast + ? insertText.slice(charCursor) + : insertText.slice( + charCursor, + toCodePointBoundary(insertText, charCursor + charsPerPtr), + ); + newMergedFromPtrs.push(ptrs[i]); + newMergedFromTexts.push(sliceText); + newMergedFromBounds.push({ + x: runningCursor, + right: runningCursor + sliceWidth, + }); + newMergedFromCharStarts.push(op.startBIdx + charCursor); + insertedPtrs.push(ptrs[i]); + runningCursor += sliceWidth; + charCursor += sliceText.length; + } + } + if (realRightEdge > lastEnd) lastEnd = realRightEdge; + // Update offset: * anchored (mixed-replacement): delta vs original + // sub-run width. + if (origBounds) { + const origWidth = origBounds.right - origBounds.x; + offset += measuredWidth - origWidth; + } else if (beforeBounds) { + offset += measuredWidth; + } else { + // The ghost-space gap also pushes everything after this insert right. + offset += leadingGap + measuredWidth; + } + } + } + + page.markNeedsGenerate(); + + if (newMergedFromBounds.length > 0) { + firstX = newMergedFromBounds[0].x; + } + + // newMergedFromCharStarts is populated inline by the ops walk above. + + return { + newMergedFromPtrs, + newMergedFromTexts, + newMergedFromBounds, + newMergedFromCharStarts, + insertedPtrs, + newBoundsX: firstX, + newBoundsWidth: lastEnd - firstX, + }; +} + +/** Paragraph-aware partial edit. */ +export interface ParagraphEditPlan { + /** Per-slot per-line plan, parallel to `run.paragraphLineSlots`. */ + perSlot: Array<{ + slotIdx: number; + plan: PartialEditPlan | null; + nextLine: string; + }>; + /** Per-VISUAL-line next text, parallel to `run.paragraphLineSlots`. */ + nextLines: string[]; + /** Snapshot of the rep's slots for revert. */ + prevSlots: ParagraphLineSlot[]; +} + +/** Count occurrences of a single char in a string. */ +function countChar(s: string, ch: string): number { + let n = 0; + for (let i = 0; i < s.length; i++) if (s[i] === ch) n++; + return n; +} + +/** True when a plan would SetText whitespace in place via a "modify" op. */ +export function planModifiesWhitespace(plan: PartialEditPlan): boolean { + return plan.ops.some( + (op) => op.type === "modify" && !!op.text && /\s/.test(op.text), + ); +} + +/** Read a text object's own font handle (0 when unavailable). */ +function objFontPtr( + m: import("@embedpdf/pdfium").WrappedPdfiumModule, + ptr: number, +): number { + const fontMod = m as unknown as FontReadingModule; + if (!ptr || !fontMod.FPDFTextObj_GetFont) return 0; + try { + return fontMod.FPDFTextObj_GetFont(ptr) || 0; + } catch { + return 0; + } +} + +// Pick the member object whose text shares the most characters with the text +// about to be emitted, and return ITS font handle. +/** + * The best font handle for `targetText` taken from the OTHER lines of the same + * paragraph, nearest line first. + * + * Only lines whose slot carries the same `fontId` are considered, so a bold or + * italic sub-run inside the paragraph cannot lend its face to plain body text. + * Returns 0 when nothing matches, leaving the caller on its normal fallback. + */ +export function siblingFontPtrForText( + m: import("@embedpdf/pdfium").WrappedPdfiumModule, + slots: ParagraphLineSlot[], + selfIndex: number, + fontId: string, + targetText: string, +): number { + const order = slots + .map((s, i) => ({ s, i })) + .filter(({ s, i }) => i !== selfIndex && s.fontId === fontId) + .sort((a, b) => Math.abs(a.i - selfIndex) - Math.abs(b.i - selfIndex)); + for (const { s } of order) { + const ptr = bestFontPtrForText( + m, + s.mergedFromPtrs, + s.mergedFromTexts, + targetText, + ); + if (ptr) return ptr; + } + return 0; +} + +export function bestFontPtrForText( + m: import("@embedpdf/pdfium").WrappedPdfiumModule, + ptrs: number[], + texts: string[], + targetText: string, +): number { + const want = new Set([...targetText].filter((c) => c.trim().length > 0)); + let bestPtr = 0; + let bestScore = 0; + for (let i = 0; i < ptrs.length; i++) { + const ptr = ptrs[i]; + if (!ptr) continue; + let score = 0; + for (const c of texts[i] ?? "") if (want.has(c)) score += 1; + if (score > bestScore) { + bestScore = score; + bestPtr = ptr; + } + } + if (bestPtr) { + const font = objFontPtr(m, bestPtr); + if (font) return font; + } + for (const ptr of ptrs) { + const font = objFontPtr(m, ptr); + if (font) return font; + } + return 0; +} + +// Locate the single contiguous edit between `prev` and `next` via a +// prefix/suffix scan. +function diffSpan( + prev: string, + next: string, +): { start: number; prevEnd: number; nextEnd: number } { + const minLen = Math.min(prev.length, next.length); + let start = 0; + while (start < minLen && prev[start] === next[start]) start++; + let end = 0; + while ( + end < minLen - start && + prev[prev.length - 1 - end] === next[next.length - 1 - end] + ) { + end++; + } + return { start, prevEnd: prev.length - end, nextEnd: next.length - end }; +} + +// Verify the slot char ranges exactly tile `text` with one-char separators +// between visual lines` per slot, a single separator at each `endChar`. +function slotsTileText(slots: ParagraphLineSlot[], text: string): boolean { + if (slots.length === 0) return false; + if (slots[0].startChar !== 0) return false; + for (let i = 0; i < slots.length; i++) { + const s = slots[i]; + if (s.endChar < s.startChar || s.endChar > text.length) return false; + if (i > 0 && s.startChar !== slots[i - 1].endChar + 1) return false; + } + return slots[slots.length - 1].endChar === text.length; +} + +export function planParagraphEdit( + run: TextRun, + prevText: string, + nextText: string, +): ParagraphEditPlan | null { + const slots = run.paragraphLineSlots; + if (slots.length < 2) return null; + if (prevText === nextText) return null; + // Slot ranges are code-unit offsets. Only refuse astral text when a slot + // boundary would cut a pair; the per-line planPartialEdit re-checks the rest. + const astral = hasAnySurrogate(prevText) || hasAnySurrogate(nextText); + if (astral) { + if (!isWellFormedUtf16(prevText) || !isWellFormedUtf16(nextText)) { + return null; + } + for (const s of slots) { + if ( + !isCodePointBoundary(prevText, s.startChar) || + !isCodePointBoundary(prevText, s.endChar) + ) { + return null; + } + } + } + // Per-VISUAL-line text comes from the slot char ranges. + if (!slotsTileText(slots, prevText)) return null; + const prevLines = slots.map((s) => prevText.slice(s.startChar, s.endChar)); + + // A change in the count of hard breaks ("\n") is a structural line add/remove + // the slot model can't express; let the line-edit path handle it. + if (countChar(prevText, "\n") !== countChar(nextText, "\n")) return null; + + // The edit must be confined to a single visual line. + const span = diffSpan(prevText, nextText); + let hitSlot = -1; + for (let i = 0; i < slots.length; i++) { + const s = slots[i]; + if (span.start >= s.startChar && span.prevEnd <= s.endChar) { + hitSlot = i; + break; + } + } + if (hitSlot < 0) return null; + + // Only the hit slot's text changes; its new length shifts by the edit + // delta. Every other visual line is untouched. + const delta = nextText.length - prevText.length; + const nextLines = prevLines.slice(); + const hit = slots[hitSlot]; + nextLines[hitSlot] = nextText.slice(hit.startChar, hit.endChar + delta); + + const perSlot: Array<{ + slotIdx: number; + plan: PartialEditPlan | null; + nextLine: string; + }> = []; + + const prevLine = prevLines[hitSlot]; + const nextLine = nextLines[hitSlot]; + if (prevLine === nextLine) return null; + // A slot with no sub-run objects can't be partially edited (e.g. an empty + // line the user just typed the first character into). + if (hit.mergedFromPtrs.length === 0) { + perSlot.push({ slotIdx: hitSlot, plan: null, nextLine }); + } else { + // Build a synthetic mini-TextRun view of the slot so the existing + // planPartialEdit / applyPartialEditPlan code can operate on it. + const slotView = makeSlotView(run, hit, prevLine); + let plan = planPartialEdit(slotView, prevLine, nextLine); + // An in-place "modify" op re-SetTexts a sub-run's surviving chars. + if (plan && planModifiesWhitespace(plan)) plan = null; + // Per-line LCS couldn't model the change - re-emit just this line + // rather than failing the whole paragraph to the overlay re-emit. + perSlot.push({ slotIdx: hitSlot, plan: plan ?? null, nextLine }); + } + + return { + perSlot, + nextLines, + prevSlots: slots.map((s) => cloneSlot(s)), + }; +} + +export interface ParagraphEditApplyResult { + newSlots: ParagraphLineSlot[]; + insertedPtrs: number[]; + newBoundsX: number; + newBoundsWidth: number; +} + +export function applyParagraphEditPlan( + doc: EditorDocument, + page: Page, + run: TextRun, + paraPlan: ParagraphEditPlan, +): ParagraphEditApplyResult { + const m = doc.module; + // Per-VISUAL-line next text from the plan (slot-range derived). + const lines = paraPlan.nextLines; + const newSlots: ParagraphLineSlot[] = run.paragraphLineSlots.map((s) => + cloneSlot(s), + ); + const planBySlot = new Map< + number, + { plan: PartialEditPlan | null; nextLine: string } + >(); + for (const entry of paraPlan.perSlot) { + planBySlot.set(entry.slotIdx, { + plan: entry.plan, + nextLine: entry.nextLine, + }); + } + + const allInsertedPtrs: number[] = []; + let minX = Infinity; + let maxRight = -Infinity; + + for (let i = 0; i < newSlots.length; i++) { + const slot = newSlots[i]; + const lineText = lines[i] ?? ""; + const planEntry = planBySlot.get(i); + if (!planEntry) { + // Unchanged line - keep slot data, just update bounds tracking. + if (slot.mergedFromBounds.length > 0) { + const first = slot.mergedFromBounds[0]; + const last = slot.mergedFromBounds[slot.mergedFromBounds.length - 1]; + if (first.x < minX) minX = first.x; + if (last.right > maxRight) maxRight = last.right; + } + continue; + } + + if (planEntry.plan === null) { + // Fresh-emit line: this line couldn't be partially edited. + const leftX = slot.mergedFromBounds[0]?.x ?? slot.matrixE; + // Read the font handle BEFORE the objects are removed. + const reuseFontPtr = + bestFontPtrForText( + m, + slot.mergedFromPtrs, + slot.mergedFromTexts, + lineText, + ) || + // A line the user just created with Enter owns no objects yet, so the + // search above has nothing to score and returns 0 - which re-emits it + // in Helvetica while the paragraph around it keeps the document's own + // face. Its SIBLING lines carry exactly the face it should inherit. + siblingFontPtrForText(m, newSlots, i, slot.fontId, lineText); + for (const ptr of slot.mergedFromPtrs) { + if (!ptr) continue; + try { + m.FPDFPage_RemoveObject(page.pagePtr, ptr); + } catch { + /* best-effort */ + } + } + const fallbackFamily = fallbackFamilyFor(run.fontId); + if (lineText.length > 0) { + const emittedTexts: string[] = []; + const ptrs = emitTextLine({ + outTexts: emittedTexts, + doc, + page, + text: lineText, + x: leftX, + y: slot.baselineY, + fontSize: slot.fontSize, + fill: run.fill, + ...inkFromRun(run), + originalFontPtr: reuseFontPtr, + originalFontSubset: slot.fontSubset, + charSpacingPt: run.charSpacingPt, + fallbackFamily, + }); + const built = buildSlotMerged(m, ptrs, lineText, leftX, emittedTexts); + slot.mergedFromPtrs = built.ptrs; + slot.mergedFromTexts = built.texts; + slot.mergedFromBounds = built.bounds; + slot.mergedFromCharStarts = built.charStarts; + // Only drop to a base-14 identity when the source font wasn't reused; + // otherwise keep the slot's font so the NEXT edit reuses it again. + if (reuseFontPtr === 0) { + slot.fontId = fallbackFontIdFor(fallbackFamily); + slot.fontSubset = false; + } + slot.containerPtr = 0; + allInsertedPtrs.push(...ptrs); + for (const b of built.bounds) { + if (b.x < minX) minX = b.x; + if (b.right > maxRight) maxRight = b.right; + } + } else { + slot.mergedFromPtrs = []; + slot.mergedFromTexts = []; + slot.mergedFromBounds = []; + slot.mergedFromCharStarts = []; + } + slot.endChar = slot.startChar + lineText.length; + continue; + } + + // Run the existing applyPartialEditPlan against the slot, emitting + // at the slot's own baseline and starting from the slot's left x. + const slotView = makeSlotView(run, slot, ""); + const result = applyPartialEditPlan( + doc, + page, + slotView, + planEntry.plan, + slot.baselineY, + slot.mergedFromBounds[0]?.x ?? slot.matrixE, + ); + slot.mergedFromPtrs = result.newMergedFromPtrs; + slot.mergedFromTexts = result.newMergedFromTexts; + slot.mergedFromBounds = result.newMergedFromBounds; + slot.mergedFromCharStarts = result.newMergedFromCharStarts; + allInsertedPtrs.push(...result.insertedPtrs); + if (result.newBoundsX < minX) minX = result.newBoundsX; + if (result.newBoundsX + result.newBoundsWidth > maxRight) { + maxRight = result.newBoundsX + result.newBoundsWidth; + } + // Update slot's char range against the new line text. + slot.endChar = slot.startChar + lineText.length; + } + + // Fix up startChar/endChar across all slots so each slot's range reflects the + // new joined text. + let cursor = 0; + for (let i = 0; i < newSlots.length; i++) { + const lineLen = (lines[i] ?? "").length; + newSlots[i].startChar = cursor; + newSlots[i].endChar = cursor + lineLen; + cursor += lineLen + (i < newSlots.length - 1 ? 1 : 0); + } + + // Re-flatten leaf ptrs from the updated slots so EditTextCommand's + // removal pass can find every original sub-object next time. + const leafPtrs: number[] = []; + const leafContainers: number[] = []; + for (const s of newSlots) { + for (const p of s.mergedFromPtrs) { + leafPtrs.push(p); + leafContainers.push(s.containerPtr); + } + } + run.paragraphLeafPtrs = leafPtrs; + run.paragraphLeafContainers = leafContainers; + + return { + newSlots, + insertedPtrs: allInsertedPtrs, + newBoundsX: isFinite(minX) ? minX : run.bounds.x, + newBoundsWidth: isFinite(maxRight) + ? maxRight - (isFinite(minX) ? minX : run.bounds.x) + : run.bounds.width, + }; +} + +// Build a synthetic TextRun "view" of a paragraph slot so the existing +// planPartialEdit / applyPartialEditPlan can operate on it. +function makeSlotView( + run: TextRun, + slot: ParagraphLineSlot, + text: string, +): TextRun { + return { + ...run, + text, + fontId: slot.fontId, + fontSize: slot.fontSize, + fontSubset: slot.fontSubset, + containerPtr: slot.containerPtr, + matrix: { ...run.matrix, e: slot.matrixE, f: slot.baselineY }, + bounds: { + x: slot.mergedFromBounds[0]?.x ?? slot.matrixE, + y: run.bounds.y, + width: + (slot.mergedFromBounds[slot.mergedFromBounds.length - 1]?.right ?? + slot.matrixE) - (slot.mergedFromBounds[0]?.x ?? slot.matrixE), + height: slot.fontSize * 1.2, + }, + mergedFromPtrs: slot.mergedFromPtrs, + mergedFromTexts: slot.mergedFromTexts, + mergedFromBounds: slot.mergedFromBounds, + mergedFromCharStarts: slot.mergedFromCharStarts, + } as TextRun; +} + +function cloneSlot(s: ParagraphLineSlot): ParagraphLineSlot { + return { + startChar: s.startChar, + endChar: s.endChar, + baselineY: s.baselineY, + matrixE: s.matrixE, + containerPtr: s.containerPtr, + fontId: s.fontId, + fontSize: s.fontSize, + fontSubset: s.fontSubset, + mergedFromPtrs: [...s.mergedFromPtrs], + mergedFromTexts: [...s.mergedFromTexts], + mergedFromBounds: s.mergedFromBounds.map((b) => ({ ...b })), + mergedFromCharStarts: [...s.mergedFromCharStarts], + }; +} diff --git a/frontend/editor/src/core/tools/pdfTextEditor/components/AnnotationOutline.css b/frontend/editor/src/core/tools/pdfTextEditor/components/AnnotationOutline.css new file mode 100644 index 0000000000..6cb74be1d9 --- /dev/null +++ b/frontend/editor/src/core/tools/pdfTextEditor/components/AnnotationOutline.css @@ -0,0 +1,21 @@ +/* Annotation-backed text: visible on the canvas, outside the editable model. */ +.pdf-editor-annotation-outline { + border: 1px dashed color-mix(in srgb, var(--c-text-subtle) 55%, transparent); + border-radius: 2px; + background: transparent; + cursor: help; + transition: + border-color 120ms ease, + background-color 120ms ease; +} + +.pdf-editor-annotation-outline:hover { + border-color: color-mix(in srgb, var(--c-primary) 90%, transparent); + background: color-mix(in srgb, var(--c-primary) 8%, transparent); +} + +@media (prefers-reduced-motion: reduce) { + .pdf-editor-annotation-outline { + transition: none; + } +} diff --git a/frontend/editor/src/core/tools/pdfTextEditor/components/AnnotationOutline.tsx b/frontend/editor/src/core/tools/pdfTextEditor/components/AnnotationOutline.tsx new file mode 100644 index 0000000000..f5d95e6521 --- /dev/null +++ b/frontend/editor/src/core/tools/pdfTextEditor/components/AnnotationOutline.tsx @@ -0,0 +1,80 @@ +import { useTranslation } from "react-i18next"; +import type { AnnotationBox } from "@app/tools/pdfTextEditor/model/AnnotationBox"; +import type { DisplayTransform } from "@app/tools/pdfTextEditor/model/DisplayTransform"; +import "@app/tools/pdfTextEditor/components/AnnotationOutline.css"; + +interface AnnotationOutlineProps { + annotation: AnnotationBox; + pageHeight: number; + transform: DisplayTransform; + scale: number; +} + +// FreeText/widget/stamp text is painted by FPDF_ANNOT but lives outside the +// page-object tree the editor walks, so it is visible and not editable. Outline +// it and say so rather than leaving the user to wonder why clicking does +// nothing. +export function AnnotationOutline({ + annotation, + pageHeight, + transform, + scale, +}: AnnotationOutlineProps) { + const { t } = useTranslation(); + const { rect, kind } = annotation; + + // Raw-PDF AABB -> display-PDF space -> CSS px. All FOUR corners go through + // the transform: on a /Rotate page two corners give the wrong box. + const corners = [ + transform.apply(rect.x, rect.y), + transform.apply(rect.x + rect.width, rect.y), + transform.apply(rect.x, rect.y + rect.height), + transform.apply(rect.x + rect.width, rect.y + rect.height), + ]; + const minX = Math.min(...corners.map((c) => c.x)); + const maxX = Math.max(...corners.map((c) => c.x)); + const minY = Math.min(...corners.map((c) => c.y)); + const maxY = Math.max(...corners.map((c) => c.y)); + const left = minX * scale; + const top = (pageHeight - maxY) * scale; + const width = (maxX - minX) * scale; + const height = (maxY - minY) * scale; + if (!(width > 1 && height > 1)) return null; + + const label = + kind === "widget" + ? t( + "pdfTextEditor.annotations.widget", + "Form field - not page text, so it can't be edited here", + ) + : kind === "freetext" + ? t( + "pdfTextEditor.annotations.freetext", + "Annotation text - not page text, so it can't be edited here", + ) + : t( + "pdfTextEditor.annotations.stamp", + "Stamp annotation - not page text, so it can't be edited here", + ); + + return ( +

+ ); +} diff --git a/frontend/editor/src/core/tools/pdfTextEditor/components/EditorFileInputs.tsx b/frontend/editor/src/core/tools/pdfTextEditor/components/EditorFileInputs.tsx new file mode 100644 index 0000000000..b85bc6d3c4 --- /dev/null +++ b/frontend/editor/src/core/tools/pdfTextEditor/components/EditorFileInputs.tsx @@ -0,0 +1,34 @@ +interface FileInputsProps { + onPickPdf: (file: File) => void; + onPickImage: (file: File) => void; +} + +/** Hidden file inputs used by the toolbar buttons, drag-and-drop, and tests. */ +export function EditorFileInputs({ onPickPdf, onPickImage }: FileInputsProps) { + return ( + <> + { + const file = e.target.files?.[0]; + if (file) onPickPdf(file); + e.target.value = ""; + }} + /> + { + const file = e.target.files?.[0]; + if (file) onPickImage(file); + e.target.value = ""; + }} + /> + + ); +} diff --git a/frontend/editor/src/core/tools/pdfTextEditor/components/EditorFileSwitcher.tsx b/frontend/editor/src/core/tools/pdfTextEditor/components/EditorFileSwitcher.tsx new file mode 100644 index 0000000000..b782700913 --- /dev/null +++ b/frontend/editor/src/core/tools/pdfTextEditor/components/EditorFileSwitcher.tsx @@ -0,0 +1,66 @@ +import { Stack, Text } from "@mantine/core"; +import { useTranslation } from "react-i18next"; +import DescriptionIcon from "@mui/icons-material/DescriptionOutlined"; +import { Button } from "@app/ui/Button"; +import { useAllFiles, useFileSelection } from "@app/contexts/FileContext"; +import type { FileId } from "@app/types/file"; + +interface Props { + /** Workbench file the editor currently holds, when it came from one. */ + currentFileId: FileId | null; + /** Open the picked file; the editor never follows the selection on its own. */ + onPick: (file: File) => void; +} + +/** + * Switch which workbench file the editor is editing. + * + * The editor owns the whole canvas, so the workbench's own Active Files grid is + * a view away; without this the user can open the tool with several files + * loaded and have no way to say which one to edit. Picking here sets the + * workbench selection rather than loading directly, so the rest of the app + * agrees about which file is being worked on. + */ +export function EditorFileSwitcher({ currentFileId, onPick }: Props) { + const { t } = useTranslation(); + const { files } = useAllFiles(); + const { setSelectedFiles } = useFileSelection(); + + const pdfs = files.filter((f) => /\.pdf$/i.test(f.name)); + if (pdfs.length < 2) return null; + + return ( + + + {t("pdfTextEditor.sidebar.document", "Document")} + + {pdfs.map((file) => { + const fileId = (file as File & { fileId?: FileId }).fileId; + const current = fileId != null && fileId === currentFileId; + return ( + + ); + })} + + ); +} diff --git a/frontend/editor/src/core/tools/pdfTextEditor/components/EditorSaveBar.tsx b/frontend/editor/src/core/tools/pdfTextEditor/components/EditorSaveBar.tsx new file mode 100644 index 0000000000..853da07efc --- /dev/null +++ b/frontend/editor/src/core/tools/pdfTextEditor/components/EditorSaveBar.tsx @@ -0,0 +1,113 @@ +import { Box, Group, Text, Tooltip } from "@mantine/core"; +import { useTranslation } from "react-i18next"; +import { Button } from "@app/ui/Button"; +import DownloadIcon from "@mui/icons-material/FileDownloadOutlined"; +import { EditorFileSwitcher } from "@app/tools/pdfTextEditor/components/EditorFileSwitcher"; +import type { FileId } from "@app/types/file"; + +interface Props { + openedFileName: string | null; + dirty: boolean; + /** Workbench file currently open, so the switcher can mark it. */ + currentFileId: FileId | null; + /** Open a different workbench file. */ + onPickFile: (file: File) => void; + onSave: () => void; + onDownload: () => void; +} + +/** + * Pinned footer: what file you are editing, and the one action that finishes. + * + * Save is the primary verb - it lands the edit back in the workbench like + * every other tool. Download is the same save plus a file, so it rides along + * as a subordinate icon rather than a second full-width button competing for + * the same attention. + */ +export function EditorSaveBar({ + openedFileName, + dirty, + currentFileId, + onPickFile, + onSave, + onDownload, +}: Props) { + const { t } = useTranslation(); + return ( + + {/* Choosing which file to edit is navigation, not a document fact, so it + stays reachable here rather than behind the Document tab. Renders + nothing until the workbench holds more than one PDF. */} + + {openedFileName && ( + // The name truncates but the unsaved marker must not, so it sits in + // its own non-shrinking element rather than inside the ellipsis. + + + {openedFileName} + + {dirty && ( + + {t("pdfTextEditor.unsaved", "(unsaved)")} + + )} + + )} + + + + + + + + + + + + {hasSelection ? ( + + ) : ( + + )} + + + + + + + ); +} + +/** What the Selected tab shows before the user has picked anything. */ +function NothingSelected() { + const { t } = useTranslation(); + return ( +
+ + + + {t("pdfTextEditor.inspector.nothingSelected", "Nothing selected")} + + + {t( + "pdfTextEditor.inspector.nothingSelectedHint", + "Click any text or image on the page to edit it here.", + )} + + +
+ ); +} + +/** + * One line about the selected runs' font - or nothing at all. + * + * It speaks only when a character the user types might not survive: a missing + * glyph, or an embedded face whose coverage we could not read. A font that can + * render everything says nothing, because "all fine" is not worth a line. + */ +function useSelectedFontNote( + state: EditorViewState, + selection: SelectionState, +): string | null { + const { t } = useTranslation(); + return useMemo(() => { + if (selection.runIds.length === 0) return null; + const picked = new Set(selection.runIds); + const fontIds = new Set(); + for (const page of state.pages) + for (const run of page.runs) + if (picked.has(run.id)) fontIds.add(run.fontId); + if (fontIds.size === 0) return null; + + const fonts = analyzePageFonts(state.pages).filter((f) => + // analyzePageFonts keys by display name + status, so match on the names + // the selected runs' fonts resolve to. + Array.from(fontIds).some((id) => id.endsWith(f.name)), + ); + if (fonts.length !== 1) return null; + const font = fonts[0]; + const gaps = font.coverage.known ? font.coverage.missing : []; + if (gaps.length > 0) { + return t( + "pdfTextEditor.inspector.fontGap", + "{{name}} · missing {{glyphs}} - typing those falls back to Helvetica.", + { name: font.name, glyphs: gaps.slice(0, 6).join(" ") }, + ); + } + // Silent when the font can render anything the user types: a standard + // base-14 face, or an embedded one whose cmap we read and found complete. + if (font.status === "standard") return null; + if (font.coverage.known) return null; + return t( + "pdfTextEditor.inspector.fontEmbedded", + "Embedded font · a character it lacks falls back to Helvetica.", + ); + }, [state.pages, selection.runIds, t]); +} + +function EmptySidebar({ + loading, + progress, +}: { + loading: boolean; + progress: LoadProgress | null; +}) { + const { t } = useTranslation(); + return ( + + + {t("pdfTextEditor.sidebar.noFile", "No file loaded")} + + + {t( + "pdfTextEditor.sidebar.noFileHint", + "Pick a PDF from the Files panel on the left, or drop one in. The editor will open it automatically.", + )} + + {loading && ( + + + {progress?.stage ?? + t("pdfTextEditor.sidebar.opening", "Opening document...")} + + {progress && progress.total > 0 && ( + + {progress.current} / {progress.total} + + )} + + )} + + ); +} diff --git a/frontend/editor/src/core/tools/pdfTextEditor/components/FindBar.tsx b/frontend/editor/src/core/tools/pdfTextEditor/components/FindBar.tsx new file mode 100644 index 0000000000..4971464710 --- /dev/null +++ b/frontend/editor/src/core/tools/pdfTextEditor/components/FindBar.tsx @@ -0,0 +1,351 @@ +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { Group, Stack, Text, TextInput, Tooltip } from "@mantine/core"; +import { Button } from "@app/ui/Button"; +import { useTranslation } from "react-i18next"; +import CloseIcon from "@mui/icons-material/Close"; +import { EditTextCommand } from "@app/tools/pdfTextEditor/commands/EditTextCommand"; +import { CompositeCommand } from "@app/tools/pdfTextEditor/commands/CompositeCommand"; +import { + findMatches, + replaceMatches, +} from "@app/tools/pdfTextEditor/util/textMatching"; +import type { + MatchOptions, + TextMatch, +} from "@app/tools/pdfTextEditor/util/textMatching"; +import { ensureAllPagesRead } from "@app/tools/pdfTextEditor/hooks/useDocumentLoader"; +import type { EditorStore } from "@app/tools/pdfTextEditor/store/EditorStore"; +import type { + PageSnapshot, + TextRunSnapshot, +} from "@app/tools/pdfTextEditor/types"; + +interface FindBarProps { + store: EditorStore; + pages: PageSnapshot[]; + onClose: () => void; +} + +interface Match { + pageIndex: number; + runId: string; + /** Run snapshot (cached so navigation can scroll to it). */ + run: TextRunSnapshot; + /** Every occurrence inside this run, as offsets into `run.text`. */ + ranges: TextMatch[]; +} + +/** + * In-document find + replace. Searches every loaded TextRun snapshot + * for the query (case, whole-word and accent handling come from the + * toggles), tracks the current match, and scrolls / selects it. + * Replace and Replace All rewrite the matching runs via batched + * `EditTextCommand`s. + * + * Triggered from Ctrl+F in PdfTextEditor. Matches that haven't been + * lazy-loaded yet won't show until the user scrolls past those pages + * (the `ensurePageRead` hook will populate them on intersection). + */ +export function FindBar({ store, pages, onClose }: FindBarProps) { + const { t } = useTranslation(); + const inputRef = useRef(null); + const [query, setQuery] = useState(""); + const [replace, setReplace] = useState(""); + const [matchCase, setMatchCase] = useState(false); + const [wholeWord, setWholeWord] = useState(false); + const [ignoreAccents, setIgnoreAccents] = useState(false); + const [activeIndex, setActiveIndex] = useState(0); + const [replaceCount, setReplaceCount] = useState(null); + + useEffect(() => { + inputRef.current?.focus(); + }, []); + + // Opening Find is a document-wide request, so pull in every page that lazy + // loading has not read yet. Yield first: the read is synchronous, and on a + // long document it would otherwise block before the bar has painted. + useEffect(() => { + const id = setTimeout(() => ensureAllPagesRead(store), 0); + return () => clearTimeout(id); + }, [store]); + + const options: MatchOptions = useMemo( + () => ({ matchCase, wholeWord, ignoreAccents }), + [matchCase, wholeWord, ignoreAccents], + ); + + const matches: Match[] = useMemo(() => { + if (!query) return []; + const out: Match[] = []; + for (const page of pages) { + for (const run of page.runs) { + const ranges = findMatches(run.text, query, options); + if (ranges.length > 0) { + out.push({ pageIndex: page.pageIndex, runId: run.id, run, ranges }); + } + } + } + return out; + }, [query, pages, options]); + + const focusMatch = useCallback( + (idx: number) => { + const m = matches[idx]; + if (!m) return; + store.selection.selectOne(m.runId); + store.selection.highlight.set(m.runId); + const el = document.querySelector( + `[data-testid="pdf-editor-run-${m.runId}"]`, + ); + el?.scrollIntoView({ block: "center", behavior: "smooth" }); + }, + [matches, store], + ); + + // Clear the highlight when the find bar unmounts. + useEffect(() => () => store.selection.highlight.set(null), [store]); + + const next = useCallback(() => { + if (matches.length === 0) return; + const idx = (activeIndex + 1) % matches.length; + setActiveIndex(idx); + focusMatch(idx); + }, [activeIndex, matches.length, focusMatch]); + + const prev = useCallback(() => { + if (matches.length === 0) return; + const idx = (activeIndex - 1 + matches.length) % matches.length; + setActiveIndex(idx); + focusMatch(idx); + }, [activeIndex, matches.length, focusMatch]); + + // Scroll the very first match into view when the SEARCH changes (query or + // a toggle) - and only then. `matches` also recomputes on every document + // edit (page snapshots refresh), and resetting to match #1 + stealing + // selection/scroll on each keystroke elsewhere was hostile. + const searchKey = `${matchCase ? 1 : 0}${wholeWord ? 1 : 0}${ + ignoreAccents ? 1 : 0 + }\u0000${query}`; + const lastSearchRef = useRef("000\u0000"); + useEffect(() => { + if (lastSearchRef.current !== searchKey) { + lastSearchRef.current = searchKey; + setActiveIndex(0); + setReplaceCount(null); + if (matches.length > 0) focusMatch(0); + } else if (activeIndex >= matches.length && matches.length > 0) { + // Matches shrank under the current index (an edit removed some); + // clamp without stealing focus. + setActiveIndex(0); + } + }, [searchKey, matches, focusMatch, activeIndex]); + + /** + * Replace the CURRENT match with the replace text. Dispatches one + * EditTextCommand. Every occurrence inside that run is swapped in a + * single pass so a run like "Foo foo FOO" becomes "bar bar bar" - + * matches the user's mental model of "replace happens to the + * highlighted run" without surprising them with partial mutations. + * The replacement is spliced literally, so "$&" stays "$&". + */ + const doReplaceOne = useCallback(() => { + if (!query) return; + const m = matches[activeIndex]; + if (!m) return; + // A locked run is still findable, but must not be rewritten. + if (m.run.locked) return; + const updated = replaceMatches(m.run.text, m.ranges, replace); + if (updated === m.run.text) return; + store.dispatch( + new EditTextCommand({ + pageIndex: m.pageIndex, + runId: m.runId, + nextText: updated, + }), + ); + setReplaceCount(1); + }, [query, replace, matches, activeIndex, store]); + + /** + * Replace EVERY match. Each affected run gets one EditTextCommand, + * batched into a single CompositeCommand so "Undo undoes the whole + * Replace all". + */ + const doReplaceAll = useCallback(() => { + if (!query || matches.length === 0) return; + let n = 0; + const cmds: EditTextCommand[] = []; + for (const m of matches) { + // Skip locked runs: the lock is a user instruction, not a hint. + if (m.run.locked) continue; + const updated = replaceMatches(m.run.text, m.ranges, replace); + if (updated === m.run.text) continue; + cmds.push( + new EditTextCommand({ + pageIndex: m.pageIndex, + runId: m.runId, + nextText: updated, + }), + ); + n += 1; + } + if (cmds.length === 1) store.dispatch(cmds[0]); + else if (cmds.length > 1) store.dispatch(new CompositeCommand(cmds)); + setReplaceCount(n); + }, [query, replace, matches, store]); + + return ( + + + + {t("pdfTextEditor.find.title", "Find & replace")} + + + + + + + + + + + + + {matches.length === 0 + ? query + ? t("pdfTextEditor.find.noMatches", "No matches") + : t("pdfTextEditor.find.typeToSearch", "Type to search") + : t("pdfTextEditor.find.count", "{{current}} of {{total}}", { + current: activeIndex + 1, + total: matches.length, + })} + {replaceCount !== null + ? t("pdfTextEditor.find.replaced", " · {{count}} replaced", { + count: replaceCount, + }) + : ""} + + + + + + + + ); +} diff --git a/frontend/editor/src/core/tools/pdfTextEditor/components/FontFamilySelect.tsx b/frontend/editor/src/core/tools/pdfTextEditor/components/FontFamilySelect.tsx new file mode 100644 index 0000000000..a29e1738a6 --- /dev/null +++ b/frontend/editor/src/core/tools/pdfTextEditor/components/FontFamilySelect.tsx @@ -0,0 +1,199 @@ +import { useCallback, useMemo, useState, useSyncExternalStore } from "react"; +import { Group, Select, Text, Tooltip } from "@mantine/core"; +import type { ComboboxData, ComboboxItemGroup } from "@mantine/core"; +import { useTranslation } from "react-i18next"; +import { Button } from "@app/ui/Button"; +import FontDownloadIcon from "@mui/icons-material/FontDownloadOutlined"; +import { + groupByFamily, + isLocalFontAccessSupported, + listLocalFonts, + loadedLocalFonts, + subscribeLocalFonts, +} from "@app/tools/pdfTextEditor/util/localFonts"; + +export interface FontFamilyOption { + value: string; + label: string; +} + +/** Base-14 families, renderable by every viewer without embedding. */ +export const BUILT_IN_FONT_FAMILIES: FontFamilyOption[] = [ + { value: "Helvetica", label: "Helvetica" }, + { value: "Helvetica-Bold", label: "Helvetica Bold" }, + { value: "Times-Roman", label: "Times Roman" }, + { value: "Times-Bold", label: "Times Bold" }, + { value: "Times-Italic", label: "Times Italic" }, + { value: "Courier", label: "Courier" }, + { value: "Courier-Bold", label: "Courier Bold" }, +]; + +type DeviceFontNotice = "unavailable" | "none"; + +interface FontFamilySelectProps { + value: string | null; + onChange: (family: string) => void; + mixed?: boolean; + disabled?: boolean; +} + +/** Font picker. Device fonts are additive: no prompt until the user asks. */ +export function FontFamilySelect({ + value, + onChange, + mixed = false, + disabled = false, +}: FontFamilySelectProps) { + const { t } = useTranslation(); + const [loading, setLoading] = useState(false); + const [notice, setNotice] = useState(null); + const supported = useMemo(() => isLocalFontAccessSupported(), []); + // Read the fonts from the module, not local state: switching files remounts + // the toolbar, and the grant the user already gave must survive that. + const localFonts = useSyncExternalStore( + subscribeLocalFonts, + loadedLocalFonts, + loadedLocalFonts, + ); + + const deviceFamilies = useMemo(() => { + if (!localFonts) return []; + const builtIn = new Set( + BUILT_IN_FONT_FAMILIES.map((option) => option.value.toLowerCase()), + ); + return groupByFamily(localFonts) + .map((family) => family.family) + .filter((family) => !builtIn.has(family.toLowerCase())); + }, [localFonts]); + + const loadDeviceFonts = useCallback(async () => { + setLoading(true); + setNotice(null); + try { + const fonts = await listLocalFonts(); + // deviceFamilies recomputes off the store, so only the empty outcomes + // need reporting here. + if (!fonts) setNotice("unavailable"); + else if (fonts.length === 0) setNotice("none"); + } finally { + setLoading(false); + } + }, []); + + const isKnown = useCallback( + (family: string) => + BUILT_IN_FONT_FAMILIES.some((option) => option.value === family) || + deviceFamilies.includes(family), + [deviceFamilies], + ); + + // The run's own face when we hold no bytes for it. Shown so the user can see + // what the text IS, listed disabled so picking it can't substitute Helvetica. + const documentFamily = useMemo( + () => (!mixed && value && !isKnown(value) ? value : null), + [mixed, value, isKnown], + ); + + const data = useMemo(() => { + if (deviceFamilies.length === 0 && !documentFamily) { + return BUILT_IN_FONT_FAMILIES; + } + const groups: ComboboxItemGroup[] = []; + if (documentFamily) { + groups.push({ + group: t("pdfTextEditor.fontPicker.documentGroup", "Document font"), + items: [ + { value: documentFamily, label: documentFamily, disabled: true }, + ], + }); + } + groups.push({ + group: t("pdfTextEditor.fontPicker.builtInGroup", "Built-in fonts"), + items: BUILT_IN_FONT_FAMILIES, + }); + if (deviceFamilies.length > 0) { + groups.push({ + group: t("pdfTextEditor.fontPicker.deviceGroup", "Device fonts"), + items: deviceFamilies.map((family) => ({ + value: family, + label: family, + })), + }); + } + return groups; + }, [deviceFamilies, documentFamily, t]); + + // Mantine shows nothing for a value with no matching option; the document + // font is in `data` precisely so a recognised face still gets named. + const selected = useMemo(() => { + if (mixed || !value) return null; + return isKnown(value) || documentFamily === value ? value : null; + }, [mixed, value, isKnown, documentFamily]); + + return ( + + setSpellcheckLang(value ?? SPELLCHECK_AUTO)} + disabled={!pref.enabled} + aria-label={t( + "pdfTextEditor.spellcheck.language", + "Dictionary language", + )} + data-testid="pdf-editor-spellcheck-language" + /> + + ); +} diff --git a/frontend/editor/src/core/tools/pdfTextEditor/components/TextRunOverlay.css b/frontend/editor/src/core/tools/pdfTextEditor/components/TextRunOverlay.css new file mode 100644 index 0000000000..8bccca1bc2 --- /dev/null +++ b/frontend/editor/src/core/tools/pdfTextEditor/components/TextRunOverlay.css @@ -0,0 +1,24 @@ +.pdf-editor-run { + position: absolute; + padding: 2px; + margin: 0; + cursor: text; + pointer-events: auto; + user-select: text; + translate: -2px -2px; +} + +.pdf-editor-run.is-pristine, +.pdf-editor-run.is-pristine * { + color: transparent !important; + -webkit-text-fill-color: transparent !important; + -webkit-text-stroke-color: transparent !important; + text-decoration-color: transparent !important; +} + +.pdf-editor-run.is-pristine::selection, +.pdf-editor-run.is-pristine *::selection { + background: color-mix(in srgb, var(--c-primary) 28%, transparent); + color: transparent; + -webkit-text-fill-color: transparent; +} diff --git a/frontend/editor/src/core/tools/pdfTextEditor/components/TextRunOverlay.tsx b/frontend/editor/src/core/tools/pdfTextEditor/components/TextRunOverlay.tsx new file mode 100644 index 0000000000..ee16ddf884 --- /dev/null +++ b/frontend/editor/src/core/tools/pdfTextEditor/components/TextRunOverlay.tsx @@ -0,0 +1,1073 @@ +import { useEffect, useMemo, useRef, useState } from "react"; +import { useTranslation } from "react-i18next"; +import type { + TextRunSnapshot, + WidthMode, +} from "@app/tools/pdfTextEditor/types"; +import { toCssHex } from "@app/tools/pdfTextEditor/model/Color"; +import type { DisplayTransform } from "@app/tools/pdfTextEditor/model/DisplayTransform"; +import { + resolveLang, + useSpellcheckPreference, +} from "@app/tools/pdfTextEditor/util/spellcheck"; +import { + embeddedFaceFamily, + onEmbeddedFaceLoaded, +} from "@app/tools/pdfTextEditor/util/embeddedFace"; +import { nearestStandardFont } from "@app/tools/pdfTextEditor/util/fontFamily"; +import { fitTextToWidth, NO_FIT } from "@app/tools/pdfTextEditor/util/fitText"; +import { + sampleRunBackground, + toOpaqueCss, +} from "@app/tools/pdfTextEditor/util/canvasBackground"; +import { buildExactLines } from "@app/tools/pdfTextEditor/util/exactLayout"; +import { stackLineBoxes } from "@app/tools/pdfTextEditor/util/lineLayout"; +import { + isLinePainted, + normalizeContainerCaret, + type PaintLine, + paintLines, + paintPlainText, + plainCaretOffset, + readOverlayText, + refitEditedTokens, + refitTokens, + restoreCaretOffset, +} from "@app/tools/pdfTextEditor/util/overlayPainter"; +import { + cssFontShorthand, + measureFontMetrics, + measureLongestTokenWidth, + measureMaxLineWidth, + resetTextMetricsCache, +} from "@app/tools/pdfTextEditor/util/textMetrics"; +import "@app/tools/pdfTextEditor/components/TextRunOverlay.css"; + +const RENDER_MODE_INVISIBLE = 3; + +const SETTLE_MS = 400; + +const STALL_MS = 250; + +// Idle time before a wrap-mode run re-wraps. This has to be longer than the gap +// between keystrokes: a reflow physically moves the glyph objects, so one that +// lands mid-burst drags the text - and the caret - out from under the user. +// Measured at 180ms it fired 10 times across 70 typed characters and produced +// 13 backward caret jumps. It only needs to beat the user clicking away. +const LIVE_WRAP_MS = 700; + +// Un-measured keystrokes a run absorbs before the overlay takes over the +// glyphs. One or two are re-rendered fast enough to leave the page's own ink +// alone; a burst is not. +const GUESSED_EDITS_BEFORE_MASK = 2; + +// Map a font id like "base14:Helvetica-Bold" or "pdf:1234:Arial" to a CSS +// font-family stack that visually approximates the PDFium-rendered glyphs. +function cssFontFamilyFor(fontId: string): string { + const idx = fontId.lastIndexOf(":"); + const family = idx >= 0 ? fontId.slice(idx + 1) : fontId; + // The document's own face, when PDFium gave us bytes a FontFace accepts. + // An unresolved name costs nothing: the browser moves on to the next entry. + const own = ownFaceFor(fontId); + // An edit that outgrew a subset now re-emits in the user's INSTALLED face + // (`device:Calibri`), so the page really is Calibri. Naming it first keeps + // the overlay measuring and drawing what the page renders; without it + // nearestStandardFont collapses it to Helvetica and every advance the + // overlay predicts is a different font's. + if (fontId.startsWith("device:")) { + return `"${family}", ${own}"Liberation Sans", "Helvetica Neue", Helvetica, Arial, sans-serif`; + } + const standard = nearestStandardFont(family); + if (standard.startsWith("Times")) { + return `${own}"Liberation Serif", "Times New Roman", Times, serif`; + } + if (standard.startsWith("Courier")) { + return `${own}"Liberation Mono", "Courier New", Courier, monospace`; + } + return `${own}"Liberation Sans", "Helvetica Neue", Helvetica, Arial, sans-serif`; +} + +/** `"pdfface-N", ` for a `pdf::` id, else the empty string. */ +function ownFaceFor(fontId: string): string { + const m = /^pdf:(\d+):/.exec(fontId); + return m ? `"${embeddedFaceFamily(Number(m[1]))}", ` : ""; +} + +function cssWeightFor(fontId: string): number { + return /bold/i.test(fontId) ? 700 : 400; +} + +function cssStyleFor(fontId: string): "italic" | "normal" { + return /italic|oblique/i.test(fontId) ? "italic" : "normal"; +} + +// Read the page bitmap under a run and return an opaque CSS colour for the +// editing mask. Null when the canvas is unreadable, so callers keep a default. +function readMaskColor(el: HTMLDivElement): string | null { + const page = el.closest("[data-testid^='pdf-editor-page-']"); + const canvas = page?.querySelector("canvas") as HTMLCanvasElement | null; + if (!canvas) return null; + const cb = canvas.getBoundingClientRect(); + if (cb.width < 1 || cb.height < 1) return null; + const rb = el.getBoundingClientRect(); + // CSS px -> canvas px: the bitmap is rendered at its own device scale. + const sx = canvas.width / cb.width; + const sy = canvas.height / cb.height; + const rgb = sampleRunBackground(canvas, { + x: (rb.left - cb.left) * sx, + y: (rb.top - cb.top) * sy, + width: rb.width * sx, + height: rb.height * sy, + }); + return rgb ? toOpaqueCss(rgb) : null; +} + +/** Pick an editing-mask color that always contrasts with the text fill. */ +function contrastingMaskFor(fill: { + r: number; + g: number; + b: number; + a: number; +}): string { + // ITU-R BT.601 luma; 0 = black, 255 = white. + const luma = (fill.r * 299 + fill.g * 587 + fill.b * 114) / 1000; + return luma > 160 ? "rgba(30, 30, 30, 0.85)" : "rgba(255, 255, 255, 0.9)"; +} + +// Put the caret at the end of the LAST painted line block rather than at the +// container's end. A container-level caret makes Firefox insert typed text as +// a bare sibling of the line div, which then reads back as an extra line. +function caretToEnd(el: HTMLElement, sel: Selection): void { + let node: Node = el; + while (node.lastChild) node = node.lastChild; + const range = document.createRange(); + if (node.nodeType === Node.TEXT_NODE) { + range.setStart(node, (node.textContent ?? "").length); + range.collapse(true); + } else if (node !== el && node.parentNode) { + // Trailing filler
: sit just before it, still inside its block. + range.setStartBefore(node); + range.collapse(true); + } else { + range.selectNodeContents(el); + range.collapse(false); + } + sel.removeAllRanges(); + sel.addRange(range); +} + +interface ExactLayout { + lines: PaintLine[]; + leftPx: number; + topPx: number; + widthPx: number; + heightPx: number; + signature: string; +} + +function computeExactLayout(args: { + run: TextRunSnapshot; + transform: DisplayTransform; + pageHeight: number; + scale: number; + font: string; + fontSizePx: number; + lineHeightPx: number; + ascent: number; + descent: number; +}): ExactLayout | null { + const { run, transform, pageHeight, scale } = args; + if (!run.charStartsX || !run.charEndsX) return null; + const exact = buildExactLines(run.text, { + starts: run.charStartsX, + ends: run.charEndsX, + }); + if (!exact || exact.length === 0) return null; + + // Slot lefts are indexed by line, so an edit that added or removed a line + // makes every entry below it describe a different line - the same length + // guard the baselines already get. + const slotLefts = + run.paragraphLineLefts?.length === exact.length + ? run.paragraphLineLefts + : undefined; + const lineLefts = exact.map((line, i) => { + const fromSlot = slotLefts?.[i]; + if (fromSlot !== undefined && Number.isFinite(fromSlot)) return fromSlot; + if (Number.isFinite(line.left)) return line.left; + return i === 0 ? run.matrix.e : run.bounds.x; + }); + + const baselines = baselinesFor(run, exact.length); + if (!baselines) return null; + + const anchors = baselines.map((y, i) => transform.apply(lineLefts[i], y)); + const leftsPx = anchors.map((a) => a.x * scale); + const baselineTopsPx = anchors.map((a) => (pageHeight - a.y) * scale); + + const halfLeading = Math.max( + 0, + (args.lineHeightPx - (args.ascent + args.descent)) / 2, + ); + const stack = stackLineBoxes( + baselineTopsPx, + args.lineHeightPx, + halfLeading + args.ascent, + ); + if (!stack) return null; + + const leftPx = Math.min(...leftsPx); + if (!Number.isFinite(leftPx) || !Number.isFinite(stack.topPx)) return null; + + const lines: PaintLine[] = exact.map((line, i) => ({ + tokens: line.tokens.map((t) => ({ + text: t.text, + advancePx: t.width * scale, + })), + heightPx: args.lineHeightPx, + marginTopPx: stack.marginTopsPx[i], + marginLeftPx: leftsPx[i] - leftPx, + })); + + const widthPx = Math.max( + run.bounds.width * scale, + ...lines.map( + (l) => l.marginLeftPx + l.tokens.reduce((sum, t) => sum + t.advancePx, 0), + ), + ); + const heightPx = + lines.reduce((sum, l) => sum + l.marginTopPx + l.heightPx, 0) + + args.descent; + if (!Number.isFinite(widthPx) || !Number.isFinite(heightPx)) return null; + const signature = [ + args.font, + leftPx.toFixed(2), + stack.topPx.toFixed(2), + ...lines.map((l) => + [ + l.marginTopPx.toFixed(2), + l.marginLeftPx.toFixed(2), + l.tokens.length, + l.tokens.reduce((sum, t) => sum + t.advancePx, 0).toFixed(2), + ].join(","), + ), + ].join("|"); + return { lines, leftPx, topPx: stack.topPx, widthPx, heightPx, signature }; +} + +// PDF advance per em for every character the run already carries. Scale-free, +// so it stays valid as the user zooms. +function charAdvancesEm(run: TextRunSnapshot): Map | null { + const starts = run.charStartsX; + const ends = run.charEndsX; + if (!starts || !ends || starts.length !== run.text.length) return null; + if (!(run.fontSize > 0)) return null; + const map = new Map(); + for (let i = 0; i < run.text.length; i += 1) { + const width = ends[i] - starts[i]; + if (!Number.isFinite(width) || width <= 0) continue; + const ch = run.text[i]; + if (!map.has(ch)) map.set(ch, width / run.fontSize); + } + return map.size > 0 ? map : null; +} + +function baselinesFor( + run: TextRunSnapshot, + lineCount: number, +): number[] | null { + const stored = run.paragraphBaselines; + if (stored && stored.length === lineCount && stored.every(Number.isFinite)) { + return stored; + } + if (lineCount === 1) return [run.matrix.f]; + const step = + run.paragraphLineHeight && run.paragraphLineHeight > 0 + ? run.paragraphLineHeight + : run.fontSize * 1.2; + const out: number[] = []; + for (let i = 0; i < lineCount; i += 1) out.push(run.matrix.f - i * step); + return out; +} + +interface TextRunOverlayProps { + run: TextRunSnapshot; + pageHeight: number; + /** Page width in PDF points - caps the box so it never runs off-page. */ + pageWidth: number; + /** Raw-PDF -> display (CropBox/rotation) transform. */ + transform: DisplayTransform; + scale: number; + /** "grow": box widens to the right. "wrap": locked width, wraps down. */ + widthMode: WidthMode; + selected: boolean; + /** True when this run is the active find-match (yellow highlight). */ + highlighted?: boolean; + pageRevision?: number; + onSelect: (shiftKey: boolean) => void; + onEdit: (nextText: string) => void; + /** Fires when the user Ctrl+drags the run to a new position. dx/dy are PDF points. */ + onMove?: (dx: number, dy: number) => void; + // Fires on blur in Wrap mode when the edited content overflows the locked box + // width. + onWrap?: (maxWidthPt: number) => void; +} + +/** + * Which gesture the pointer is over: the frame, or the text interior. + * + * There is deliberately no resize zone. Re-wrapping to an arbitrary width goes + * through ReflowWrapCommand, whose word grouping is x-gap based - on a run + * whose glyphs are individually positioned (letter-spaced headings, button + * labels) every glyph becomes its own "word" and the line breaker splits + * inside words, shredding "Open Source" into one character per line. Until + * that grouping is token-aware, a drag handle would make the corruption a + * one-gesture accident. + */ +type EdgeZone = "move" | null; + +/** Grab band around the box, in CSS px. Matches the visible ring's reach. */ +const EDGE_PX = 7; + +/** + * Classify a pointer position against the run's own box. + * + * The box is contentEditable, so handles cannot be child elements without + * becoming editable content. Hit-testing the border band instead gives the + * same affordance with no DOM inside the editable region. + */ +function edgeZoneAt( + el: HTMLElement, + clientX: number, + clientY: number, +): EdgeZone { + const r = el.getBoundingClientRect(); + const nearLeft = clientX - r.left <= EDGE_PX; + const nearRight = r.right - clientX <= EDGE_PX; + const nearTop = clientY - r.top <= EDGE_PX; + const nearBottom = r.bottom - clientY <= EDGE_PX; + if (nearTop || nearBottom || nearLeft || nearRight) return "move"; + return null; +} + +/** One editable HTML element per PDF text run. */ +export function TextRunOverlay({ + run, + pageHeight, + pageWidth, + transform, + scale, + widthMode, + selected, + highlighted, + pageRevision, + onSelect, + onEdit, + onMove, + onWrap, +}: TextRunOverlayProps) { + const { t } = useTranslation(); + // Subscribed, so toggling the preference re-renders every overlay. + const spellcheck = useSpellcheckPreference(); + const ref = useRef(null); + const [hovered, setHovered] = useState(false); + const [focused, setFocused] = useState(false); + // Masking a run the user has only clicked into swaps real PDF ink for a + // CSS approximation, so hold the pristine bitmap until an actual edit. + const [touched, setTouched] = useState(false); + const [editTick, setEditTick] = useState(0); + const [stalled, setStalled] = useState(false); + const editedAtRevisionRef = useRef(-1); + // Keystrokes taken since the engine last measured this run, and when the + // overlay's glyphs first came due because of them. + const guessedEditsRef = useRef(0); + const maskDueSinceRef = useRef(0); + const paintedSignatureRef = useRef(null); + const pointerFocusRef = useRef(false); + // The mask has to be the page's own colour, not a guess from the text: a + // run on a coloured page got a grey band. Sampled from the rendered bitmap + // once per focus, so the read never lands in the typing path. + const [maskColor, setMaskColor] = useState(null); + const [faceEpoch, setFaceEpoch] = useState(0); + // True between compositionstart and compositionend (IME). While composing + // onInput must not dispatch per-keystroke edits; we commit once on end. + const composingRef = useRef(false); + // Text content captured when the box gains focus, so blur can tell whether + // the user actually edited it (and a Wrap reflow is warranted). + const focusTextRef = useRef(""); + // Drag-to-move state. `dragOffset` is the live cursor delta applied as a + // CSS transform so the box follows the cursor during the drag. + const dragOriginRef = useRef<{ x: number; y: number } | null>(null); + const [dragging, setDragging] = useState(false); + const [dragOffset, setDragOffset] = useState<{ x: number; y: number } | null>( + null, + ); + // Which edge the pointer is over, so the cursor can advertise the gesture + // before the user commits to it. Null means the text interior. + const [edgeZone, setEdgeZone] = useState(null); + const originalBoundsWidthRef = useRef(run.bounds.width); + // Whether this run was a real (multi-line) paragraph when it first mounted. + + const fontFamily = cssFontFamilyFor(run.fontId); + const fontWeight = cssWeightFor(run.fontId); + const fontStyle = cssStyleFor(run.fontId); + const fontSizePx = Math.max(4, run.fontSize * scale); + const font = cssFontShorthand(fontStyle, fontWeight, fontSizePx, fontFamily); + const { ascent, descent } = useMemo( + () => measureFontMetrics(font, fontSizePx), + [font, fontSizePx, faceEpoch], + ); + + const lineHeightPx = + run.paragraphLineHeight && run.paragraphLineHeight > 0 + ? run.paragraphLineHeight * scale + : fontSizePx * 1.2; + + const freshExact = useMemo( + () => + computeExactLayout({ + run, + transform, + pageHeight, + scale, + font, + fontSizePx, + lineHeightPx, + ascent, + descent, + }), + [ + run, + transform, + pageHeight, + scale, + font, + fontSizePx, + lineHeightPx, + ascent, + descent, + ], + ); + + // How the run's own text axis is rotated on the page, if it is. cos/sin come + // straight from the text matrix; screen y runs the other way from PDF y, so + // the CSS angle is the negation. + const runRotation = useMemo(() => { + const norm = Math.hypot(run.matrix.a, run.matrix.b); + // The run's own slant, if any. Screen y runs opposite to PDF y, so the CSS + // angle is the negation of the matrix angle. + const own = norm + ? -Math.atan2(run.matrix.b / norm, run.matrix.a / norm) * (180 / Math.PI) + : 0; + // Plus the page's own quarter-turns. `transform.apply` already puts the + // anchor in the right place on a /Rotate page, but the box was still drawn + // along the PAGE's x-axis while the glyphs ran down it, so a box on a + // /Rotate 90 page stuck up to 247px off the right-hand edge. + const pageDeg = ((((transform.rotate ?? 0) % 4) + 4) % 4) * 90; + const deg = own + pageDeg; + if (Math.abs(deg) < 0.01) return null; + return { deg }; + }, [run.matrix.a, run.matrix.b, transform.rotate]); + + const heldExactRef = useRef(null); + if (freshExact) heldExactRef.current = freshExact; + // An exact layout is built from per-character x positions along the PAGE's + // x-axis, which stop describing a run whose own axis is rotated - the box + // came out axis-aligned over slanted glyphs and covered 38% of its own ink. + // Rotated runs use the flow geometry plus a matching CSS rotation instead. + const exact = runRotation + ? null + : (freshExact ?? (focused ? heldExactRef.current : null)); + if (!freshExact && !focused) heldExactRef.current = null; + + const advanceEm = useMemo(() => charAdvancesEm(run), [run]); + // Kept across the edit: the engine drops the pen positions the moment the + // text changes, and a token typed into needs them most right then. + const heldAdvanceEmRef = useRef | null>(null); + if (advanceEm) heldAdvanceEmRef.current = advanceEm; + + useEffect(() => { + const bump = () => { + resetTextMetricsCache(); + setFaceEpoch((n) => n + 1); + }; + const unsubscribe = onEmbeddedFaceLoaded(bump); + let cancelled = false; + if (typeof document !== "undefined" && document.fonts) { + void document.fonts.ready.then(() => { + if (!cancelled) bump(); + }); + } + return () => { + cancelled = true; + unsubscribe(); + }; + }, []); + + useEffect(() => { + const el = ref.current; + if (!el) return; + const onBeforeInput = (event: Event) => { + const inputType = (event as InputEvent).inputType ?? ""; + if (inputType.startsWith("format")) event.preventDefault(); + // The browser keeps its OWN undo stack for a contenteditable, reachable + // from the Edit menu and trackpad gestures. Letting it fire would rewrite + // the overlay behind the editor's command history, so the two disagree + // about the document. Undo/redo has to come through the command stack. + if (inputType === "historyUndo" || inputType === "historyRedo") { + event.preventDefault(); + } + }; + el.addEventListener("beforeinput", onBeforeInput); + return () => el.removeEventListener("beforeinput", onBeforeInput); + }, []); + + useEffect(() => { + if (!touched) return; + if (pageRevision === undefined) return; + if (pageRevision <= editedAtRevisionRef.current) return; + const timer = window.setTimeout(() => setTouched(false), SETTLE_MS); + return () => window.clearTimeout(timer); + }, [pageRevision, touched, editTick]); + + // No exact layout for the text now in the box: the engine only re-measures + // pen positions once typing pauses, so until it does the overlay is placing + // glyphs on the browser's advances rather than the PDF's. + const layoutIsGuessed = touched && !freshExact; + + const paintOpts = { + font, + fontSizePx, + advanceEm: heldAdvanceEmRef.current, + }; + + useEffect(() => { + if (!layoutIsGuessed) { + guessedEditsRef.current = 0; + maskDueSinceRef.current = 0; + setStalled(false); + return; + } + guessedEditsRef.current += 1; + // The mask replaces the page's own ink with a CSS approximation of it, so + // arming it mid-word visibly changes the typeface of text the user is + // typing into - and changes it back when the engine catches up. That is a + // worse artefact than the caret leading the page render, which is all it + // buys: the raster is simply slower than a fast burst, and it self-corrects + // the moment typing pauses. So it stays reserved for a run that has fallen + // BEHIND ITS OWN PAGE RENDER - not for one whose page is merely mid-flight. + if ( + pageRevision !== undefined && + pageRevision > editedAtRevisionRef.current + ) { + setStalled(false); + return; + } + if (guessedEditsRef.current < GUESSED_EDITS_BEFORE_MASK) return; + // The deadline is anchored where the mask first came due, so typing on does + // not keep pushing it out of reach. + const now = Date.now(); + if (maskDueSinceRef.current === 0) maskDueSinceRef.current = now; + const wait = Math.max(0, STALL_MS - (now - maskDueSinceRef.current)); + const timer = window.setTimeout(() => setStalled(true), wait); + return () => window.clearTimeout(timer); + }, [layoutIsGuessed, pageRevision, editTick]); + + useEffect(() => { + const el = ref.current; + if (!el || composingRef.current) return; + if (!isLinePainted(el)) return; + refitTokens(el, paintOpts); + }, [font, fontSizePx]); + + useEffect(() => { + const el = ref.current; + if (!el) return; + const active = document.activeElement === el; + // Focus may sit on a descendant mid-edit; either way the run owns the caret + // and is entitled to re-seat it. A blurred run is not. + const ownsFocus = el.contains(document.activeElement); + if (active && composingRef.current) return; + const domText = readOverlayText(el); + // Mid-edit, the only layout allowed to repaint is one the engine has + // already measured for exactly this text. It re-seats the typed glyphs on + // the PDF's own advances - without it the overlay keeps laying them out at + // the browser's, and the caret walks off the text on the page a fraction of + // a pixel per keystroke. Any other layout would be fighting a keystroke + // still in flight. + if (active && touched && !(freshExact && domText === run.text)) return; + const wantSignature = freshExact ? freshExact.signature : ""; + if (!freshExact && isLinePainted(el) && domText === run.text) return; + if ( + domText === run.text && + paintedSignatureRef.current === wantSignature && + isLinePainted(el) === !!freshExact + ) { + return; + } + // Keyed off the selection, not the focus: replaceChildren below detaches + // whatever node the caret sits in, and a caret this run holds without being + // document.activeElement is still a caret the next insert needs. + const caret = plainCaretOffset(el); + if (freshExact) { + paintLines(el, freshExact.lines, paintOpts); + } else { + paintPlainText(el, run.text); + } + paintedSignatureRef.current = wantSignature; + // Only while the run still holds focus. A selection outlives the blur that + // ended the edit, so re-seating it into a blurred run takes focus BACK - + // and the user's next click elsewhere then fires this run's blur handler, + // dispatching a spurious wrap that also wipes the redo stack. + if (caret !== null && ownsFocus) restoreCaretOffset(el, caret); + }, [run.text, freshExact, font, fontSizePx, touched, faceEpoch]); + + const anchor = transform.apply(run.matrix.e, run.matrix.f); + const flowLeft = anchor.x * scale; + + const invisible = run.renderMode === RENDER_MODE_INVISIBLE; + const showsGlyphs = (dragging || stalled) && !invisible; + + const singleLine = (run.paragraphLineCount ?? 1) <= 1; + const fit = + !exact && showsGlyphs && singleLine + ? fitTextToWidth( + run.text, + measureMaxLineWidth(run.text, font), + run.bounds.width * scale, + fontSizePx, + ) + : NO_FIT; + + // VERTICAL PLACEMENT - anchor the first line's CSS alphabetic baseline + // exactly onto the PDF baseline (`run.matrix.f`). + const halfLeading = Math.max(0, (lineHeightPx - (ascent + descent)) / 2); + const firstBaselineFromTop = halfLeading + ascent; + const baselineScreen = (pageHeight - anchor.y) * scale; + const flowTop = baselineScreen - firstBaselineFromTop; + + // Height covers every line plus descender slack. + const lineCount = Math.max(1, run.text.split(/\r?\n/).length); + const flowHeight = lineCount * lineHeightPx + descent; + + const pdfWidth = run.bounds.width * scale; + // Widen the overlay so every source line still fits in CSS metrics, and so + // typed text wider than the original bounds isn't clipped. + const measuredWidth = measureMaxLineWidth(run.text, font); + // Width behaviour is user-controlled: - "grow": box widens to the right to + // fit the content. + const wrapMode = widthMode === "wrap"; + const wrapLockWidth = Math.max( + originalBoundsWidthRef.current * scale, + fontSizePx * 4, + ); + // The mode the user picked, and nothing else. Forcing a paragraph to wrap in + // Grow made the two modes indistinguishable for body text and contradicted + // the control's own hint ("Boxes widen to the right as you type (no + // wrapping)"). + const wantWrap = wrapMode; + const left = exact ? exact.leftPx : flowLeft; + // Wrap keeps the box on the page - that is the whole point of the mode, and + // its overflow goes onto new lines instead. Grow has nowhere to put the + // overflow, so capping it there just hides what the user is typing: it grew + // to the page edge and then clipped everything beyond, measured at 2944px of + // invisible text on a single-line run. + const pageCap = Math.max(fontSizePx * 4, pageWidth * scale - left - 4); + // Wrapping cannot break inside a word, so a box narrower than the longest one + // hides its tail however the lines are broken - 707px of a held-down key + // measured invisible, with the caret out there past the box edge. + // + // The longest token overrides even the page edge. Stopping there is right for + // text that can wrap, because the overflow has somewhere else to go; a word + // with no break in it has nowhere, so the cap stops protecting the page + // margin and just hides what the user is typing. + const longestTokenWidth = measureLongestTokenWidth(run.text, font); + const wrapWidth = Math.max( + Math.min(wrapLockWidth, pageCap), + longestTokenWidth + fontSizePx, + ); + const maxOnPageWidth = wantWrap ? pageCap : Number.POSITIVE_INFINITY; + const naturalWidth = wantWrap + ? wrapLockWidth + : Math.max(pdfWidth, measuredWidth + fontSizePx); + const flowWidth = Math.min(naturalWidth, maxOnPageWidth); + + const top = exact ? exact.topPx : flowTop; + // Width must not depend on anything that can flip between renders, or the box + // visibly pumps between two sizes while the user types. Two things could: + // room for the caret appeared only WHILE focused, and the measured fallback + // dropped out the moment `freshExact` arrived. The engine now re-measures + // every 100ms, so both flipped about ten times a second. Always keep the + // slack, always take the wider of the two - the result is a pure function of + // the layout, the text and the font, and a few pixels of margin costs + // nothing next to a box that will not sit still. + const exactWidth = exact + ? Math.max(exact.widthPx + fontSizePx * 0.5, measuredWidth + fontSizePx) + : 0; + // Capped at the page edge: an editing box hanging off the page reads as + // broken, and the glyphs under it would be off-page anyway. + // + // A line longer than that is therefore clipped while it is being typed, and + // the reflow on blur brings it back onto the page. The alternative - letting + // the box wrap the line - is what put the overlay a full line out of register + // with the bitmap: the PDF draws each line as ONE text object at one pen + // origin and cannot wrap, so an overlay that wraps stops describing the page + // underneath it. + // Wrap holds its width and pushes overflow onto new lines; widening to the + // page edge instead is Grow's job, and doing both makes the modes identical. + const width = wantWrap ? wrapWidth : exact ? exactWidth : flowWidth; + const height = exact ? exact.heightPx : flowHeight; + // An exact layout is never wrapped - its lines are the PDF's own. Only the + // plain-text fallback, where CSS flow genuinely owns the layout, may wrap. + const whiteSpace: "pre" | "pre-wrap" = + !exact && wantWrap ? "pre-wrap" : "pre"; + + // Wrap AS THE USER TYPES, not only on blur. Deferring it meant the overflow + // sat invisible past the box edge until they clicked away - over a thousand + // pixels of it - and the caret only dropped onto the new line at that point. + // The reflow shares EditTextCommand's coalesce key and ignores the time + // window, so running it mid-burst does not fragment undo. + const wrapTarget = wrapWidth; + useEffect(() => { + if (!wantWrap || !onWrap || !focused) return; + const el = ref.current; + if (!el || composingRef.current) return; + const widest = measureMaxLineWidth(readOverlayText(el), font); + if (widest <= wrapTarget + 1) return; + const timer = window.setTimeout( + () => onWrap(wrapTarget / scale), + LIVE_WRAP_MS, + ); + return () => window.clearTimeout(timer); + }, [wantWrap, onWrap, focused, editTick, wrapTarget, font, scale]); + + // Which dictionary the browser should load. "auto" falls back to the + // page's own language, which is what the element would inherit anyway. + const spellcheckLang = resolveLang( + spellcheck, + typeof document === "undefined" ? null : document.documentElement.lang, + ); + + const pristine = !showsGlyphs; + + return ( +
{ + // A caret parked on the container (a click past the text lands there) + // makes Firefox insert the keystroke as a sibling of the line blocks, + // which reads back as a line the user never typed. Seat it in the + // block it sits beside before the input applies. + const sel = window.getSelection(); + if (sel) + normalizeContainerCaret(e.currentTarget as HTMLDivElement, sel); + }} + onPaste={(e) => { + // Paste as PLAIN TEXT. + e.preventDefault(); + const sel = window.getSelection(); + if (sel) + normalizeContainerCaret(e.currentTarget as HTMLDivElement, sel); + const text = e.clipboardData?.getData("text/plain"); + if (text) document.execCommand("insertText", false, text); + }} + onPointerDown={(e) => { + // Ctrl+Shift+drag is the marquee multi-select gesture. + if ((e.ctrlKey || e.metaKey) && e.shiftKey) return; + e.stopPropagation(); + // Locked runs are inert: no select, no drag, no edit. + if (run.locked) return; + const zone = edgeZoneAt( + e.currentTarget as HTMLDivElement, + e.clientX, + e.clientY, + ); + + // Ctrl+drag still moves from anywhere inside, so existing muscle + // memory keeps working; grabbing the frame is the discoverable path. + if ((e.ctrlKey || e.metaKey || zone === "move") && onMove) { + const viaFrame = zone === "move" && !(e.ctrlKey || e.metaKey); + if (viaFrame) e.preventDefault(); + dragOriginRef.current = { x: e.clientX, y: e.clientY }; + setDragging(true); + setDragOffset({ x: 0, y: 0 }); + (e.currentTarget as HTMLDivElement).blur(); + // Pointer events (mouse/pen/touch) with a global capture so the + // drag keeps tracking even if the cursor leaves the overlay. + const onPointerMove = (ev: PointerEvent) => { + const origin = dragOriginRef.current; + if (!origin) return; + setDragOffset({ + x: ev.clientX - origin.x, + y: ev.clientY - origin.y, + }); + }; + const onPointerUp = (ev: PointerEvent) => { + window.removeEventListener("pointermove", onPointerMove); + window.removeEventListener("pointerup", onPointerUp); + setDragging(false); + setDragOffset(null); + const origin = dragOriginRef.current; + dragOriginRef.current = null; + if (!origin) return; + // Screen delta -> display-PDF delta, then invert the linear part of + // the CropBox/rotation transform to a raw-PDF delta. + const ddx = (ev.clientX - origin.x) / scale; + const ddy = -(ev.clientY - origin.y) / scale; + const v = transform.invertVector(ddx, ddy); + const dx = v.x; + const dy = v.y; + // Below the drag threshold nothing moved. From the frame that is + // a plain click (select); with Ctrl held it is the multi-select + // gesture it looks like. + if (Math.abs(dx) < 0.5 && Math.abs(dy) < 0.5) { + onSelect(!viaFrame); + return; + } + onMove(dx, dy); + }; + window.addEventListener("pointermove", onPointerMove); + window.addEventListener("pointerup", onPointerUp); + return; + } + // Shift-click EXTENDS the multi-object selection. + if (e.shiftKey) { + e.preventDefault(); + onSelect(true); + return; + } + pointerFocusRef.current = true; + (e.currentTarget as HTMLDivElement).focus({ preventScroll: true }); + onSelect(false); + }} + onFocus={(e) => { + setFocused(true); + setTouched(false); + setMaskColor(readMaskColor(e.currentTarget as HTMLDivElement)); + const el = e.currentTarget as HTMLDivElement; + // Remember the text at focus so blur can tell if the user edited it. + focusTextRef.current = readOverlayText(el); + const fromPointer = pointerFocusRef.current; + pointerFocusRef.current = false; + const sel = window.getSelection(); + if ( + !fromPointer && + sel && + !(sel.rangeCount > 0 && el.contains(sel.anchorNode)) + ) { + caretToEnd(el, sel); + } + // Backend strategy: pre-warm the per-char charcode cache for the whole + // page in the background. + void (async () => { + try { + const [ + { getActiveCharcodeStrategy }, + { prewarmBackendCacheForPage }, + ] = await Promise.all([ + import("@app/tools/pdfTextEditor/charcode/CharcodeStrategy"), + import("@app/tools/pdfTextEditor/charcode/charcodeRegistry"), + ]); + if (getActiveCharcodeStrategy() !== "backend") return; + await prewarmBackendCacheForPage(run.pageIndex); + } catch { + /* prewarm is best-effort, never block focus */ + } + })(); + }} + onBlur={(e) => { + setTouched(false); + setMaskColor(null); + setFocused(false); + // WebKit routes keystrokes to the SELECTION even when the element has + // lost focus, so typing after a click-away landed in the run just + // left. Once focus is genuinely outside the run, its selection goes + // with it. + { + const el = e.currentTarget as HTMLDivElement; + const sel = window.getSelection(); + if ( + sel && + sel.focusNode && + el.contains(sel.focusNode) && + !(e.relatedTarget instanceof Node && el.contains(e.relatedTarget)) + ) { + sel.removeAllRanges(); + } + } + // Wrap mode: when the just-edited content overflows the locked box + // width. + if (!wantWrap || !onWrap) return; + const el = e.currentTarget as HTMLDivElement; + const domText = readOverlayText(el); + if (domText === focusTextRef.current) return; // not edited + const widest = measureMaxLineWidth(domText, font); + // Reflow to the box the user locked, NOT to `width` - with an exact + // layout that is however wide the text grew, so nothing ever overflows. + // + // Never below the locked width, though. `maxOnPageWidth` keeps a GROWN + // box on the page and holds back 4px to do it, so for a run that + // already spans most of the page it comes out a point or two under the + // width the document itself laid the text out at. Reflowing there costs + // every line its last word - "...carry out various" wraps "various" + // onto a line of its own, on lines the user never touched. The locked + // width is by definition one the text fitted in. + const target = wrapLockWidth; + // Only when something actually overflows. Reflowing a paragraph + // unconditionally re-breaks lines the user never touched: the reflow + // rebuilds every line, so a two-character edit that still fits could + // still move words between lines the moment the box lost focus. The + // base branch never reflowed here at all. + if (widest <= target + 1) return; + onWrap(target / scale); + }} + onCompositionStart={() => { + composingRef.current = true; + }} + onCompositionEnd={(e) => { + composingRef.current = false; + // Commit the composed string once, like onInput's non-IME path. + const el = e.currentTarget as HTMLDivElement; + onEdit(readOverlayText(el).replace(/\u00A0/g, " ")); + }} + onInput={(e) => { + setTouched(true); + setEditTick((n) => n + 1); + editedAtRevisionRef.current = pageRevision ?? -1; + // Skip intermediate IME steps; compositionend commits the result. + if (composingRef.current || (e.nativeEvent as InputEvent).isComposing) + return; + const el = e.currentTarget as HTMLDivElement; + // Re-fit the token the user just typed into. Its painted width is the + // PDF's advance for the ORIGINAL string, so leaving it alone lays the + // new text out at the browser's own advances and the caret drifts off + // the glyphs on the page, a pixel or so per keystroke. + if (isLinePainted(el)) refitEditedTokens(el, paintOpts); + // Always read hard breaks only - never synthesise newlines from browser + // soft-wraps. + const raw = readOverlayText(el); + const text = raw.replace(/\u00A0/g, " "); + onEdit(text); + // No per-keystroke reflow: while focused, the box is CAPPED to the page + // and wraps via CSS, so the editing view is always on-page. + }} + onMouseEnter={() => setHovered(true)} + onMouseLeave={() => { + setHovered(false); + setEdgeZone(null); + }} + onPointerMove={(e) => { + // Only while idle: mid-drag the cursor is owned by the gesture. + if (run.locked || dragging) return; + setEdgeZone( + edgeZoneAt(e.currentTarget as HTMLDivElement, e.clientX, e.clientY), + ); + }} + style={{ + left, + top, + width, + minHeight: height, + // Live Ctrl+drag preview: follow the cursor via transform, and + // float above siblings + dim slightly so the move reads clearly. + // Drag preview and the width fit both live here, so compose them. + transform: + [ + dragOffset ? `translate(${dragOffset.x}px, ${dragOffset.y}px)` : "", + // Turn the box with the text. Placed before scaleX so the fit still + // stretches along the run's own axis rather than the page's. + runRotation ? `rotate(${runRotation.deg}deg)` : "", + fit.scaleX !== 1 ? `scaleX(${fit.scaleX})` : "", + ] + .filter(Boolean) + .join(" ") || undefined, + // Rotate about the text's own origin - the left end of its first + // baseline - which is the point the flow geometry positions. Otherwise + // scale from the run's own origin, never its centre. + transformOrigin: runRotation + ? `0 ${firstBaselineFromTop}px` + : fit.scaleX !== 1 + ? "0 50%" + : undefined, + opacity: dragging ? 0.75 : 1, + zIndex: dragging ? 20 : undefined, + // Only the opacity settle is animated. + transition: dragging ? "none" : "opacity 120ms ease-out", + // While focused: real glyphs in a CSS-stack approximation of the PDFium + // font, so the user sees their input before the bitmap re-renders. + fontFamily, + fontWeight, + fontStyle, + fontSize: fontSizePx, + letterSpacing: + !exact && (run.charSpacingPt || fit.letterSpacing) + ? `${(run.charSpacingPt ?? 0) * scale + fit.letterSpacing}px` + : undefined, + // Same line-height used in the baseline math above, so the CSS + // baselines land exactly where we computed `top`. + lineHeight: `${lineHeightPx}px`, + whiteSpace, + // Show the glyphs once the run is really being changed, or mid-drag so + // the Ctrl+drag preview is a visible chip that follows the cursor. + color: showsGlyphs ? toCssHex(run.fill) : "transparent", + WebkitTextStrokeColor: + showsGlyphs && run.stroke ? toCssHex(run.stroke) : undefined, + WebkitTextStrokeWidth: + showsGlyphs && run.stroke && run.strokeWidth + ? `${run.strokeWidth * scale}px` + : undefined, + backgroundColor: showsGlyphs + ? (maskColor ?? contrastingMaskFor(run.fill)) + : highlighted + ? "rgba(255,217,0,0.45)" + : selected + ? "rgba(44,123,229,0.10)" + : hovered + ? "rgba(44,123,229,0.04)" + : "transparent", + caretColor: toCssHex(run.fill), + // Selected keeps a ring: the 10% tint alone is near-invisible over a + // coloured band. Locked gets a muted ring so it does not read as + // something you can type into. + outline: run.locked + ? hovered || selected + ? "1px solid rgba(120,120,120,0.55)" + : "1px dashed transparent" + : dragging + ? "2px solid #2c7be5" + : selected + ? edgeZone + ? "2px solid #2c7be5" + : "1px solid #2c7be5" + : hovered + ? "1px dashed rgba(44,123,229,0.5)" + : "1px dashed transparent", + // The cursor is the affordance: the box advertises move/resize on the + // frame and keeps the I-beam over the text. + cursor: run.locked + ? "default" + : dragging + ? "grabbing" + : edgeZone === "move" + ? "grab" + : undefined, + overflow: "hidden", + }} + /> + ); +} diff --git a/frontend/editor/src/core/tools/pdfTextEditor/components/Toolbar.tsx b/frontend/editor/src/core/tools/pdfTextEditor/components/Toolbar.tsx new file mode 100644 index 0000000000..a9cbb18a63 --- /dev/null +++ b/frontend/editor/src/core/tools/pdfTextEditor/components/Toolbar.tsx @@ -0,0 +1,578 @@ +import { useState } from "react"; +import { + ColorInput, + Group, + Menu, + NumberInput, + Popover, + Text, + Tooltip, +} from "@mantine/core"; +import { Button } from "@app/ui/Button"; +import UndoIcon from "@mui/icons-material/Undo"; +import RedoIcon from "@mui/icons-material/Redo"; +import DeleteIcon from "@mui/icons-material/DeleteOutlined"; +import FormatItalicIcon from "@mui/icons-material/FormatItalic"; +import TuneIcon from "@mui/icons-material/TuneOutlined"; +import LockIcon from "@mui/icons-material/LockOutlined"; +import LockOpenIcon from "@mui/icons-material/LockOpenOutlined"; +import TextFieldsIcon from "@mui/icons-material/TextFields"; +import ExpandMoreIcon from "@mui/icons-material/ExpandMore"; +import LayersIcon from "@mui/icons-material/LayersOutlined"; +import FlipToFrontIcon from "@mui/icons-material/FlipToFrontOutlined"; +import FlipToBackIcon from "@mui/icons-material/FlipToBackOutlined"; +import ArrowUpwardIcon from "@mui/icons-material/ArrowUpward"; +import ArrowDownwardIcon from "@mui/icons-material/ArrowDownward"; +import VerticalAlignTopIcon from "@mui/icons-material/VerticalAlignTop"; +import VerticalAlignBottomIcon from "@mui/icons-material/VerticalAlignBottom"; +import VerticalAlignCenterIcon from "@mui/icons-material/VerticalAlignCenter"; +import AlignHorizontalLeftIcon from "@mui/icons-material/AlignHorizontalLeftOutlined"; +import AlignHorizontalCenterIcon from "@mui/icons-material/AlignHorizontalCenterOutlined"; +import AlignHorizontalRightIcon from "@mui/icons-material/AlignHorizontalRightOutlined"; +import LinearScaleIcon from "@mui/icons-material/LinearScaleOutlined"; +import { useTranslation } from "react-i18next"; +import { parseCssColor, toCssHex } from "@app/tools/pdfTextEditor/model/Color"; +import { familyOf } from "@app/tools/pdfTextEditor/util/fontFamily"; +import { FontFamilySelect } from "@app/tools/pdfTextEditor/components/FontFamilySelect"; +import type { useToolbarController } from "@app/tools/pdfTextEditor/hooks/useToolbarController"; + +type Controller = ReturnType; + +/** + * The canvas toolbar: undo/redo, plus formatting for the current selection. + * + * Character formatting sits here rather than in the side panel because that is + * where every document editor puts it. The group is *contextual* - it appears + * with a selection instead of standing permanently greyed - which is what + * keeps the strip to a single row. + */ +interface ToolbarProps { + controller: Controller; +} + +function ToolbarSeparator() { + return ( + + | + + ); +} + +/** Toolbar children keep their natural width; the strip scrolls if pressed. */ +const NO_SHRINK = { flexShrink: 0 } as const; + +export function Toolbar({ controller }: ToolbarProps) { + const { t } = useTranslation(); + const hasSelection = controller.selectionCount > 0; + return ( + + + + + + {t("pdfTextEditor.toolbar.order", "Order")} + } + onClick={() => onChangeZOrder("to-front")} + data-testid="pdf-editor-z-to-front" + > + {t("pdfTextEditor.toolbar.bringToFront", "Bring to front")} + + } + onClick={() => onChangeZOrder("forward")} + data-testid="pdf-editor-z-forward" + > + {t("pdfTextEditor.toolbar.bringForward", "Bring forward")} + + } + onClick={() => onChangeZOrder("backward")} + data-testid="pdf-editor-z-backward" + > + {t("pdfTextEditor.toolbar.sendBackward", "Send backward")} + + } + onClick={() => onChangeZOrder("to-back")} + data-testid="pdf-editor-z-to-back" + > + {t("pdfTextEditor.toolbar.sendToBack", "Send to back")} + + + + {t("pdfTextEditor.toolbar.alignLabel", "Align · needs 2+ objects")} + + } + disabled={hAlignDisabled} + onClick={() => onAlign("left")} + data-testid="pdf-editor-align-left" + > + {t("pdfTextEditor.toolbar.alignLeft", "Align left")} + + } + disabled={hAlignDisabled} + onClick={() => onAlign("center-h")} + data-testid="pdf-editor-align-center-h" + > + {t("pdfTextEditor.toolbar.alignCentre", "Align centre")} + + } + disabled={hAlignDisabled} + onClick={() => onAlign("right")} + data-testid="pdf-editor-align-right" + > + {t("pdfTextEditor.toolbar.alignRight", "Align right")} + + } + disabled={alignDisabled} + onClick={() => onAlign("top")} + data-testid="pdf-editor-align-top" + > + {t("pdfTextEditor.toolbar.alignTop", "Align top")} + + } + disabled={alignDisabled} + onClick={() => onAlign("middle-v")} + data-testid="pdf-editor-align-middle-v" + > + {t("pdfTextEditor.toolbar.alignMiddle", "Align middle")} + + } + disabled={alignDisabled} + onClick={() => onAlign("bottom")} + data-testid="pdf-editor-align-bottom" + > + {t("pdfTextEditor.toolbar.alignBottom", "Align bottom")} + + + + {t( + "pdfTextEditor.toolbar.distributeLabel", + "Distribute · needs 3+ objects", + )} + + } + disabled={distributeDisabled} + onClick={() => onDistribute("horizontal")} + data-testid="pdf-editor-distribute-h" + > + {t( + "pdfTextEditor.toolbar.distributeHorizontally", + "Distribute horizontally", + )} + + + } + disabled={distributeDisabled} + onClick={() => onDistribute("vertical")} + data-testid="pdf-editor-distribute-v" + > + {t( + "pdfTextEditor.toolbar.distributeVertically", + "Distribute vertically", + )} + + + + + ); +} diff --git a/frontend/editor/src/core/tools/pdfTextEditor/components/ZoomPill.tsx b/frontend/editor/src/core/tools/pdfTextEditor/components/ZoomPill.tsx new file mode 100644 index 0000000000..4244753682 --- /dev/null +++ b/frontend/editor/src/core/tools/pdfTextEditor/components/ZoomPill.tsx @@ -0,0 +1,108 @@ +import { Group, Text, Tooltip } from "@mantine/core"; +import { useTranslation } from "react-i18next"; +import { Button } from "@app/ui/Button"; +import type { EditorStore } from "@app/tools/pdfTextEditor/store/EditorStore"; +import type { PageSnapshot } from "@app/tools/pdfTextEditor/types"; + +const Z_OUT_LIMIT = 0.25; +const Z_IN_LIMIT = 4; +const Z_STEP = 0.25; +const FIT_PAD_PX = 64; + +interface Props { + store: EditorStore; + renderScale: number; + pages: PageSnapshot[]; +} + +/** + * Zoom, floating over the pages it scales. + * + * Anchored to the canvas because it is a view control: it belongs beside what + * it acts on. Ctrl+wheel on the stage drives the same store field. + */ +export function ZoomPill({ store, renderScale, pages }: Props) { + const { t } = useTranslation(); + const zoomTo = (scale: number) => + store.setRenderScale( + +Math.min(Z_IN_LIMIT, Math.max(Z_OUT_LIMIT, scale)).toFixed(2), + ); + + return ( + + + {/* The readout doubles as the reset control: a separate "100%" button + beside a "150%" readout read as two zoom values. */} + + + + + + + ); +} diff --git a/frontend/editor/src/core/tools/pdfTextEditor/components/inspector/DocumentInspector.tsx b/frontend/editor/src/core/tools/pdfTextEditor/components/inspector/DocumentInspector.tsx new file mode 100644 index 0000000000..db81a9ec6e --- /dev/null +++ b/frontend/editor/src/core/tools/pdfTextEditor/components/inspector/DocumentInspector.tsx @@ -0,0 +1,242 @@ +import { useState } from "react"; +import { Badge, Collapse, Group, Stack, Text, Tooltip } from "@mantine/core"; +import { useTranslation } from "react-i18next"; +import { Button } from "@app/ui/Button"; +import ExpandMoreIcon from "@mui/icons-material/ExpandMore"; +import ChevronRightIcon from "@mui/icons-material/ChevronRight"; +import { + Section, + SectionLabel, + StatRow, +} from "@app/tools/pdfTextEditor/components/inspector/InspectorPrimitives"; +import { + analyzePageFonts, + type PageFont, +} from "@app/tools/pdfTextEditor/util/pageFonts"; +import { DocumentSettings } from "@app/tools/pdfTextEditor/components/inspector/DocumentSettings"; +import type { + GroupingMode, + PageSnapshot, + WidthMode, +} from "@app/tools/pdfTextEditor/types"; + +interface Props { + pages: PageSnapshot[]; + groupingMode: GroupingMode; + widthMode: WidthMode; + showRulers: boolean; + onSetGroupingMode: (mode: GroupingMode) => void; + onSetWidthMode: (mode: WidthMode) => void; + onSetShowRulers: (show: boolean) => void; +} + +/** Facts about the open document. Nothing here acts on a selection. */ +export function DocumentInspector({ pages, ...settings }: Props) { + const { t } = useTranslation(); + const runs = pages.reduce((n, p) => n + p.runs.length, 0); + const images = pages.reduce((n, p) => n + p.images.length, 0); + return ( + +
+ + {t("pdfTextEditor.inspector.document", "Document")} + + + + + + +
+ + +
+ ); +} + +const FONT_STATUS_COLOR = { + standard: "green", + embedded: "blue", + subset: "yellow", +} as const; + +/** + * Font coverage, collapsed to a single status row. + * + * The old panel banner fired on every document to say nothing was wrong. Here + * the headline is one pill; the per-font detail is one click away, and the row + * only opens itself when a font is actually missing glyphs. + */ +function FontsSection({ pages }: { pages: PageSnapshot[] }) { + const { t } = useTranslation(); + // Pure: the font list AND coverage both come from snapshot data + the cmap + // cache the loader primed during its serialized read. + const fonts = analyzePageFonts(pages); + const withGaps = fonts.filter( + (f) => f.coverage.known && f.coverage.missing.length > 0, + ); + const [open, setOpen] = useState(false); + if (fonts.length === 0) return null; + + const allConfirmedFull = + fonts.length > 0 && + fonts.every((f) => f.coverage.known && f.coverage.missing.length === 0); + const tone = withGaps.length > 0 ? "warn" : allConfirmedFull ? "ok" : "info"; + const summary = { + ok: { + color: "green", + label: t("pdfTextEditor.fonts.pill.ok", "All glyphs"), + hint: t( + "pdfTextEditor.fonts.compat.ok", + "Every font includes the full alphabet and digits - type freely.", + ), + }, + info: { + color: "blue", + label: t("pdfTextEditor.fonts.pill.info", "Embedded"), + hint: t( + "pdfTextEditor.fonts.compat.info", + "Existing text edits perfectly. A new character an embedded font doesn't include falls back to a standard font.", + ), + }, + warn: { + color: "yellow", + label: t("pdfTextEditor.fonts.pill.warn", "{{count}} with gaps", { + count: withGaps.length, + }), + hint: t( + "pdfTextEditor.fonts.compat.warnOther", + "{{count}} fonts missing some letters or numbers - typing those uses a standard fallback font.", + { count: withGaps.length }, + ), + }, + }[tone]; + + const expanded = open || tone === "warn"; + return ( +
+ + + + {fonts.map((f) => ( + + ))} + + +
+ ); +} + +/** Compact list of missing a-zA-Z0-9, e.g. "q W 7" (capped for width). */ +function formatMissing(missing: string[]): string { + const shown = missing.slice(0, 12).join(" "); + return missing.length > 12 ? `${shown} +${missing.length - 12}` : shown; +} + +function FontRow({ font }: { font: PageFont }) { + const { t } = useTranslation(); + const { known, missing } = font.coverage; + const hasGap = known && missing.length > 0; + return ( + + + + {font.name} + + + {t( + `pdfTextEditor.fonts.status.${font.status}.label`, + font.status === "standard" + ? "Standard" + : font.status === "embedded" + ? "Embedded" + : "Subset", + )} + + + {known && + (hasGap ? ( + + {t("pdfTextEditor.fonts.missing", "Missing: {{glyphs}}", { + glyphs: formatMissing(missing), + })} + + ) : ( + + {t( + "pdfTextEditor.fonts.allPresent", + "All letters & numbers present", + )} + + ))} + + ); +} diff --git a/frontend/editor/src/core/tools/pdfTextEditor/components/inspector/DocumentSettings.tsx b/frontend/editor/src/core/tools/pdfTextEditor/components/inspector/DocumentSettings.tsx new file mode 100644 index 0000000000..3aa6f9e55c --- /dev/null +++ b/frontend/editor/src/core/tools/pdfTextEditor/components/inspector/DocumentSettings.tsx @@ -0,0 +1,160 @@ +import { useState } from "react"; +import { Box, Collapse, Group, Stack, Text } from "@mantine/core"; +import { useTranslation } from "react-i18next"; +import { Button } from "@app/ui/Button"; +import { SegmentedControl } from "@app/ui/SegmentedControl"; +import { ToggleSwitch } from "@app/ui/ToggleSwitch"; +import ExpandMoreIcon from "@mui/icons-material/ExpandMore"; +import ChevronRightIcon from "@mui/icons-material/ChevronRight"; +import { SpellcheckControl } from "@app/tools/pdfTextEditor/components/SpellcheckControl"; +import { + Section, + SectionLabel, +} from "@app/tools/pdfTextEditor/components/inspector/InspectorPrimitives"; +import type { GroupingMode, WidthMode } from "@app/tools/pdfTextEditor/types"; + +interface Props { + groupingMode: GroupingMode; + widthMode: WidthMode; + showRulers: boolean; + onSetGroupingMode: (mode: GroupingMode) => void; + onSetWidthMode: (mode: WidthMode) => void; + onSetShowRulers: (show: boolean) => void; +} + +/** + * Document-level preferences, split by how often they are touched. + * + * View toggles are everyday and sit in plain sight. The two parse options are + * not: they change how the document was read, and switching grouping reloads + * it and discards undo history - so they go behind a disclosure where nobody + * flips one by accident, with the consequence spelled out next to the control. + */ +export function DocumentSettings({ + groupingMode, + widthMode, + showRulers, + onSetGroupingMode, + onSetWidthMode, + onSetShowRulers, +}: Props) { + const { t } = useTranslation(); + const [advancedOpen, setAdvancedOpen] = useState(false); + + return ( + <> +
+ {t("pdfTextEditor.settings.view", "View")} + + + {/* The row's own text names the switch; passing `label` too would + print it twice, once either side of the control. */} + + {t("pdfTextEditor.sidebar.rulers", "Rulers and guides")} + + + + + +
+ +
+ + + + + + {t("pdfTextEditor.sidebar.textGrouping", "Text grouping")} + + + + + + {t( + "pdfTextEditor.sidebar.groupingAutoHint", + "Groups equal-spaced lines into paragraphs. Changing this re-reads the document and clears undo history.", + )} + + + + + {t("pdfTextEditor.sidebar.textBoxWidth", "New text box width")} + + + + + + {t( + "pdfTextEditor.sidebar.widthGrowHint", + "Grow widens a box as you type; Wrap keeps its width and flows onto new lines.", + )} + + + + +
+ + ); +} diff --git a/frontend/editor/src/core/tools/pdfTextEditor/components/inspector/InspectorPrimitives.tsx b/frontend/editor/src/core/tools/pdfTextEditor/components/inspector/InspectorPrimitives.tsx new file mode 100644 index 0000000000..0cbaba11ff --- /dev/null +++ b/frontend/editor/src/core/tools/pdfTextEditor/components/inspector/InspectorPrimitives.tsx @@ -0,0 +1,155 @@ +import type { ReactNode } from "react"; +import { Box, Group, NumberInput, Stack, Text, Tooltip } from "@mantine/core"; +import HelpIcon from "@mui/icons-material/HelpOutlineOutlined"; + +/** Shared layout atoms for the editor's properties inspector. */ + +/** Uppercase section heading, optionally with a trailing control. */ +export function SectionLabel({ + children, + right, +}: { + children: ReactNode; + right?: ReactNode; +}) { + return ( + + + {children} + + {right} + + ); +} + +/** One bordered band. Sections stack with a hairline between them. */ +export function Section({ + children, + testId, + tinted, + first, +}: { + children: ReactNode; + testId?: string; + tinted?: boolean; + /** Topmost band in its panel: no rule above it. */ + first?: boolean; +}) { + return ( + + {children} + + ); +} + +/** Label above a control, the panel's only field layout. */ +export function Field({ + label, + hint, + children, +}: { + label: string; + hint?: string; + children: ReactNode; +}) { + return ( + + + + {label} + + {hint && } + + {children} + + ); +} + +/** The `?` that replaced the panel's permanent explanatory paragraphs. */ +export function HintIcon({ label }: { label: string }) { + return ( + + + + ); +} + +/** Read-only key/value line used by the Document tab. */ +export function StatRow({ label, value }: { label: string; value: ReactNode }) { + return ( + + + {label} + + + {value} + + + ); +} + +/** + * A points field that only commits a real change. + * + * Geometry edits dispatch undoable commands, so re-emitting the value the + * field already shows would cost a spurious undo step on every blur. + */ +export function PointsInput({ + value, + onCommit, + label, + testId, + min, + disabled, +}: { + value: number; + onCommit: (next: number) => void; + label: string; + testId?: string; + min?: number; + disabled?: boolean; +}) { + return ( + { + const n = typeof next === "number" ? next : Number(next); + if (!Number.isFinite(n)) return; + if (Math.abs(n - value) < 0.05) return; + onCommit(n); + }} + /> + ); +} diff --git a/frontend/editor/src/core/tools/pdfTextEditor/components/inspector/SelectionInspector.tsx b/frontend/editor/src/core/tools/pdfTextEditor/components/inspector/SelectionInspector.tsx new file mode 100644 index 0000000000..ae2f55bc3b --- /dev/null +++ b/frontend/editor/src/core/tools/pdfTextEditor/components/inspector/SelectionInspector.tsx @@ -0,0 +1,400 @@ +import { useMemo } from "react"; +import { Group, Stack, Text, Tooltip } from "@mantine/core"; +import { useTranslation } from "react-i18next"; +import { Button } from "@app/ui/Button"; +import ImageIcon from "@mui/icons-material/ImageOutlined"; +import CallMergeIcon from "@mui/icons-material/CallMergeOutlined"; +import CallSplitIcon from "@mui/icons-material/CallSplitOutlined"; +import RotateLeftIcon from "@mui/icons-material/RotateLeftOutlined"; +import RotateRightIcon from "@mui/icons-material/RotateRightOutlined"; +import FlipIcon from "@mui/icons-material/FlipOutlined"; +import OpenInNewIcon from "@mui/icons-material/OpenInNewOutlined"; +import { + Field, + PointsInput, + Section, + SectionLabel, +} from "@app/tools/pdfTextEditor/components/inspector/InspectorPrimitives"; +import type { SelectionGeometry } from "@app/tools/pdfTextEditor/hooks/useSelectionGeometry"; +import type { useToolbarController } from "@app/tools/pdfTextEditor/hooks/useToolbarController"; +import type { SelectionState } from "@app/tools/pdfTextEditor/types"; + +export type InspectorController = ReturnType; + +interface Props { + controller: InspectorController; + selection: SelectionState; + geometry: SelectionGeometry; + /** Font status for the selected runs, e.g. "Embedded · full alphabet". */ + fontNote: string | null; + canGroup: boolean; + canUngroup: boolean; + onGroup: () => void; + onUngroup: () => void; +} + +/** + * Properties of whatever is selected right now. + * + * Deliberately NOT the whole of the selection's UI: character formatting and + * the arrange/lock/delete verbs sit in the canvas toolbar, where document + * editors have always put them. What lands here is what needs a label and a + * number - geometry and paragraph structure. + */ +export function SelectionInspector({ + controller, + selection, + geometry, + fontNote, + canGroup, + canUngroup, + onGroup, + onUngroup, +}: Props) { + const runCount = selection.runIds.length; + const imageCount = selection.imageIds.length; + const { hasRunSelection, hasImageSelection } = controller; + + return ( + + + + {hasRunSelection && ( + + )} + {hasImageSelection && } + + ); +} + +/** Names what is selected, and how its font will treat new characters. */ +function SelectionHeader({ + runCount, + imageCount, + fontNote, +}: { + runCount: number; + imageCount: number; + fontNote: string | null; +}) { + const { t } = useTranslation(); + let title: string; + if (runCount > 0 && imageCount > 0) { + title = t("pdfTextEditor.inspector.mixed", "{{count}} objects", { + count: runCount + imageCount, + }); + } else if (runCount > 0) { + title = + runCount === 1 + ? t("pdfTextEditor.inspector.oneText", "Text") + : t("pdfTextEditor.inspector.manyText", "Text · {{count}} boxes", { + count: runCount, + }); + } else { + title = + imageCount === 1 + ? t("pdfTextEditor.inspector.oneImage", "Image") + : t("pdfTextEditor.inspector.manyImages", "{{count}} images", { + count: imageCount, + }); + } + return ( +
+ {/* Doubles as the old sidebar's selection readout, moved from the very + bottom of the panel to the top where the user is already looking. */} + + {title} + + {fontNote && ( + + {fontNote} + + )} +
+ ); +} + +/** Merge selected runs into a paragraph, or split one back into lines. */ +function ParagraphSection({ + canGroup, + canUngroup, + onGroup, + onUngroup, +}: { + canGroup: boolean; + canUngroup: boolean; + onGroup: () => void; + onUngroup: () => void; +}) { + const { t } = useTranslation(); + return ( +
+ + {t("pdfTextEditor.sidebar.paragraph", "Paragraph")} + + + + + + + + + +
+ ); +} + +/** Position and size, in PDF points, for a single selected object. */ +function GeometrySection({ + geometry, + isImage, +}: { + geometry: SelectionGeometry; + isImage: boolean; +}) { + const { t } = useTranslation(); + if (!geometry.single) { + return ( +
+ + {t("pdfTextEditor.inspector.geometry", "Position & size")} + + + {t( + "pdfTextEditor.inspector.multiGeometry", + "Select a single object to edit its position and size.", + )} + +
+ ); + } + const { bounds, setX, setY, setWidth, setHeight } = geometry.single; + return ( +
+ + {t("pdfTextEditor.inspector.geometry", "Position & size")} + + + + + + + + + + + + + {/* Read-only for text: setting a width goes through the reflow, + which splits inside words on runs whose glyphs are positioned + individually. Until that is token-aware this must not be a + one-keystroke way to shred a heading. */} + undefined} + min={1} + disabled={!isImage} + label={t("pdfTextEditor.inspector.width", "Width")} + testId="pdf-editor-size-w" + /> + + + undefined)} + min={1} + disabled={!setHeight} + label={t("pdfTextEditor.inspector.height", "Height")} + testId="pdf-editor-size-h" + /> + + + +
+ ); +} + +/** Rotate/flip plus the two ways to swap an image's pixels. */ +function ImageSection({ controller }: { controller: InspectorController }) { + const { t } = useTranslation(); + const { + onTransformImage, + onReplaceImage, + onEditImageExternally, + externalEditSupported, + } = controller; + const transforms = useMemo( + () => + [ + { + mode: "rotate-ccw" as const, + testId: "pdf-editor-imgop-rotate-ccw", + icon: , + label: t("pdfTextEditor.toolbar.rotateLeft", "Rotate 90° left"), + }, + { + mode: "rotate-cw" as const, + testId: "pdf-editor-imgop-rotate-cw", + icon: , + label: t("pdfTextEditor.toolbar.rotateRight", "Rotate 90° right"), + }, + { + mode: "flip-h" as const, + testId: "pdf-editor-imgop-flip-h", + icon: , + label: t("pdfTextEditor.toolbar.flipHorizontal", "Flip horizontal"), + }, + { + mode: "flip-v" as const, + testId: "pdf-editor-imgop-flip-v", + icon: ( + + ), + label: t("pdfTextEditor.toolbar.flipVertical", "Flip vertical"), + }, + ] as const, + [t], + ); + + return ( +
+ {t("pdfTextEditor.inspector.image", "Image")} + + + {transforms.map((tr) => ( + + + + +
+ ); +} diff --git a/frontend/editor/src/core/tools/pdfTextEditor/fontAnalysis.ts b/frontend/editor/src/core/tools/pdfTextEditor/fontAnalysis.ts deleted file mode 100644 index 87b2f92d20..0000000000 --- a/frontend/editor/src/core/tools/pdfTextEditor/fontAnalysis.ts +++ /dev/null @@ -1,504 +0,0 @@ -import { - PdfJsonDocument, - PdfJsonFont, -} from "@app/tools/pdfTextEditor/pdfTextEditorTypes"; - -export type FontStatus = - | "perfect" - | "embedded-subset" - | "system-fallback" - | "missing" - | "unknown"; - -export interface FontAnalysis { - fontId: string; - baseName: string; - status: FontStatus; - embedded: boolean; - isSubset: boolean; - isStandard14: boolean; - hasWebFormat: boolean; - webFormat?: string; - subtype?: string; - encoding?: string; - warnings: string[]; - suggestions: string[]; -} - -export interface DocumentFontAnalysis { - fonts: FontAnalysis[]; - canReproducePerfectly: boolean; - hasWarnings: boolean; - summary: { - perfect: number; - embeddedSubset: number; - systemFallback: number; - missing: number; - unknown: number; - }; -} - -/** - * Determines if a font name indicates it's a subset font. - * Subset fonts typically have a 6-character prefix like "ABCDEE+" - */ -const isSubsetFont = (baseName: string | null | undefined): boolean => { - if (!baseName) return false; - // Check for common subset patterns: ABCDEF+FontName - return /^[A-Z]{6}\+/.test(baseName); -}; - -/** - * Checks if a font is one of the standard 14 PDF fonts that are guaranteed - * to be available on all PDF readers - */ -const isStandard14Font = (font: PdfJsonFont): boolean => { - if (font.standard14Name) return true; - - const baseName = (font.baseName || "").toLowerCase().replace(/[-_\s]/g, ""); - - const standard14Patterns = [ - "timesroman", - "timesbold", - "timesitalic", - "timesbolditalic", - "helvetica", - "helveticabold", - "helveticaoblique", - "helveticaboldoblique", - "courier", - "courierbold", - "courieroblique", - "courierboldoblique", - "symbol", - "zapfdingbats", - ]; - - // Check exact matches or if the base name contains the pattern - return standard14Patterns.some((pattern) => { - // Exact match - if (baseName === pattern) return true; - // Contains pattern (e.g., "ABCDEF+Helvetica" matches "helvetica") - if (baseName.includes(pattern)) return true; - return false; - }); -}; - -/** - * Checks if a font has a fallback available on the backend. - * These fonts are embedded in the Stirling PDF backend and can be used - * for PDF export even if not in the original PDF. - * - * Based on PdfJsonFallbackFontService.java - */ -const hasBackendFallbackFont = (font: PdfJsonFont): boolean => { - const baseName = (font.baseName || "").toLowerCase().replace(/[-_\s]/g, ""); - - // Backend has these font families available (from PdfJsonFallbackFontService) - const backendFonts = [ - // Liberation fonts (metric-compatible with MS core fonts) - "arial", - "helvetica", - "arimo", - "times", - "timesnewroman", - "tinos", - "courier", - "couriernew", - "cousine", - "liberation", - "liberationsans", - "liberationserif", - "liberationmono", - // DejaVu fonts - "dejavu", - "dejavusans", - "dejavuserif", - "dejavumono", - "dejavusansmono", - // Noto fonts - "noto", - "notosans", - ]; - - return backendFonts.some((pattern) => { - if (baseName === pattern) return true; - if (baseName.includes(pattern)) return true; - return false; - }); -}; - -/** - * Extracts the base font name from a subset font name - * e.g., "ABCDEF+Arial" -> "Arial" - */ -const extractBaseFontName = ( - baseName: string | null | undefined, -): string | null => { - if (!baseName) return null; - const match = baseName.match(/^[A-Z]{6}\+(.+)$/); - return match ? match[1] : baseName; -}; - -/** - * Analyzes a single font to determine if it can be reproduced perfectly - * Takes allFonts to check if full versions of subset fonts are available - */ -export const analyzeFontReproduction = ( - font: PdfJsonFont, - allFonts?: PdfJsonFont[], -): FontAnalysis => { - const fontId = font.id || font.uid || "unknown"; - const baseName = font.baseName || "Unknown Font"; - const isSubset = isSubsetFont(font.baseName); - const isStandard14 = isStandard14Font(font); - const hasBackendFallback = hasBackendFallbackFont(font); - const embedded = font.embedded ?? false; - - // Check available web formats (ordered by preference) - const webFormats = [ - { key: "webProgram", format: font.webProgramFormat }, - { key: "pdfProgram", format: font.pdfProgramFormat }, - { key: "program", format: font.programFormat }, - ]; - - const availableWebFormat = webFormats.find((f) => f.format); - const hasWebFormat = !!availableWebFormat; - const webFormat = availableWebFormat?.format || undefined; - - const warnings: string[] = []; - const suggestions: string[] = []; - let status: FontStatus = "unknown"; - - // Check if we have the full font when this is a subset - let hasFullFontVersion = false; - if (isSubset && allFonts) { - const baseFont = extractBaseFontName(font.baseName); - if (baseFont) { - // Look for a non-subset version of this font with a web format - hasFullFontVersion = allFonts.some((f) => { - const otherBaseName = extractBaseFontName(f.baseName); - const isNotSubset = !isSubsetFont(f.baseName); - const hasFormat = !!( - f.webProgramFormat || - f.pdfProgramFormat || - f.programFormat - ); - const sameBase = - otherBaseName?.toLowerCase() === baseFont.toLowerCase(); - return sameBase && isNotSubset && hasFormat && (f.embedded ?? false); - }); - } - } - - // Analyze font status - focusing on PDF export quality - if (isStandard14) { - // Standard 14 fonts are always available in PDF readers - perfect for export! - status = "perfect"; - suggestions.push( - "Standard PDF font (Times, Helvetica, or Courier). Always available in PDF readers.", - ); - suggestions.push( - "Exported PDFs will render consistently across all PDF readers.", - ); - } else if (embedded && !isSubset) { - // Perfect: Fully embedded with complete character set - status = "perfect"; - suggestions.push( - "Font is fully embedded. Exported PDFs will reproduce text perfectly, even with edits.", - ); - } else if ( - embedded && - isSubset && - (hasFullFontVersion || hasBackendFallback) - ) { - // Subset but we have the full font or backend fallback - perfect! - status = "perfect"; - if (hasFullFontVersion) { - suggestions.push( - "Full font version is also available in the document. Exported PDFs can reproduce all characters.", - ); - } else if (hasBackendFallback) { - suggestions.push( - "Backend has the full font available. Exported PDFs can reproduce all characters, including new text.", - ); - } - } else if (embedded && isSubset) { - // Good, but subset: May have missing characters if user adds new text - status = "embedded-subset"; - warnings.push( - "This is a subset font - only specific characters are embedded in the PDF.", - ); - warnings.push( - "Exported PDFs may have missing characters if you add new text with this font.", - ); - suggestions.push( - "Existing text will export correctly. New characters may render as boxes (☐) or fallback glyphs.", - ); - } else if (!embedded && hasBackendFallback) { - // Not embedded, but backend has it - perfect for export! - status = "perfect"; - suggestions.push( - "Backend has this font available. Exported PDFs will use the backend fallback font.", - ); - suggestions.push("Text will export correctly with consistent appearance."); - } else if (!embedded) { - // Not embedded - must rely on system fonts (risky for export) - status = "missing"; - warnings.push("Font is not embedded in the PDF."); - warnings.push( - "Exported PDFs will substitute with a fallback font, which may look very different.", - ); - suggestions.push( - "Consider re-embedding fonts or accepting that the exported PDF will use fallback fonts.", - ); - } else if (embedded && !hasWebFormat) { - // Embedded but no web format available (still okay for export) - status = "perfect"; - suggestions.push( - "Font is embedded in the PDF. Exported PDFs will reproduce correctly.", - ); - suggestions.push( - "Web preview may use a fallback font, but the final PDF export will be accurate.", - ); - } - - // Additional warnings based on font properties - if (font.subtype === "Type0" && font.cidSystemInfo) { - const registry = font.cidSystemInfo.registry || ""; - const ordering = font.cidSystemInfo.ordering || ""; - if ( - registry.includes("Adobe") && - (ordering.includes("Identity") || ordering.includes("UCS")) - ) { - // CID fonts with Identity encoding are common for Asian languages - if (!embedded || !hasWebFormat) { - warnings.push("This CID font may contain Asian or Unicode characters."); - } - } - } - - if ( - font.encoding && - !font.encoding.includes("WinAnsiEncoding") && - !font.encoding.includes("MacRomanEncoding") - ) { - // Custom encodings may cause issues - if (font.encoding !== "Identity-H" && font.encoding !== "Identity-V") { - warnings.push(`Custom encoding detected: ${font.encoding}`); - } - } - - return { - fontId, - baseName, - status, - embedded, - isSubset, - isStandard14, - hasWebFormat, - webFormat, - subtype: font.subtype || undefined, - encoding: font.encoding || undefined, - warnings, - suggestions, - }; -}; - -/** - * Gets fonts used on a specific page - */ -export const getFontsForPage = ( - document: PdfJsonDocument | null, - pageIndex: number, -): PdfJsonFont[] => { - if ( - !document?.fonts || - !document?.pages || - pageIndex < 0 || - pageIndex >= document.pages.length - ) { - return []; - } - - const page = document.pages[pageIndex]; - if (!page?.textElements) { - return []; - } - - // Get unique font IDs used on this page - const fontIdsOnPage = new Set(); - page.textElements.forEach((element) => { - if (element?.fontId) { - fontIdsOnPage.add(element.fontId); - } - }); - - // Filter fonts to only those used on this page - const allFonts = document.fonts.filter( - (font): font is PdfJsonFont => font !== null && font !== undefined, - ); - - const fontsOnPage = allFonts.filter((font) => { - // Match by ID - if (font.id && fontIdsOnPage.has(font.id)) { - return true; - } - // Match by UID - if (font.uid && fontIdsOnPage.has(font.uid)) { - return true; - } - // Match by page-specific ID (pageNumber:id format) - if (font.pageNumber === pageIndex + 1 && font.id) { - const pageSpecificId = `${font.pageNumber}:${font.id}`; - if (fontIdsOnPage.has(pageSpecificId) || fontIdsOnPage.has(font.id)) { - return true; - } - } - return false; - }); - - // Deduplicate by base font name to avoid showing the same font multiple times - const uniqueFonts = new Map(); - fontsOnPage.forEach((font) => { - const baseName = - extractBaseFontName(font.baseName) || - font.baseName || - font.id || - "unknown"; - const key = baseName.toLowerCase(); - - // Keep the first occurrence, or prefer non-subset over subset - const existing = uniqueFonts.get(key); - if (!existing) { - uniqueFonts.set(key, font); - } else { - // Prefer non-subset fonts over subset fonts - const existingIsSubset = isSubsetFont(existing.baseName); - const currentIsSubset = isSubsetFont(font.baseName); - if (existingIsSubset && !currentIsSubset) { - uniqueFonts.set(key, font); - } - } - }); - - return Array.from(uniqueFonts.values()); -}; - -/** - * Analyzes all fonts in a PDF document (or just fonts for a specific page) - */ -export const analyzeDocumentFonts = ( - document: PdfJsonDocument | null, - pageIndex?: number, -): DocumentFontAnalysis => { - if (!document?.fonts || document.fonts.length === 0) { - return { - fonts: [], - canReproducePerfectly: true, - hasWarnings: false, - summary: { - perfect: 0, - embeddedSubset: 0, - systemFallback: 0, - missing: 0, - unknown: 0, - }, - }; - } - - const allFonts = document.fonts.filter( - (font): font is PdfJsonFont => font !== null && font !== undefined, - ); - - // Filter to page-specific fonts if pageIndex is provided - const fontsToAnalyze = - pageIndex !== undefined ? getFontsForPage(document, pageIndex) : allFonts; - - if (fontsToAnalyze.length === 0) { - return { - fonts: [], - canReproducePerfectly: true, - hasWarnings: false, - summary: { - perfect: 0, - embeddedSubset: 0, - systemFallback: 0, - missing: 0, - unknown: 0, - }, - }; - } - - const fontAnalyses = fontsToAnalyze.map((font) => - analyzeFontReproduction(font, allFonts), - ); - - // Calculate summary - const summary = { - perfect: fontAnalyses.filter((f) => f.status === "perfect").length, - embeddedSubset: fontAnalyses.filter((f) => f.status === "embedded-subset") - .length, - systemFallback: fontAnalyses.filter((f) => f.status === "system-fallback") - .length, - missing: fontAnalyses.filter((f) => f.status === "missing").length, - unknown: fontAnalyses.filter((f) => f.status === "unknown").length, - }; - - // Can reproduce perfectly ONLY if all fonts are truly perfect (not subsets) - const canReproducePerfectly = fontAnalyses.every( - (f) => f.status === "perfect", - ); - - // Has warnings if any font has issues (including subsets) - const hasWarnings = fontAnalyses.some( - (f) => - f.warnings.length > 0 || - f.status === "missing" || - f.status === "system-fallback" || - f.status === "embedded-subset", - ); - - return { - fonts: fontAnalyses, - canReproducePerfectly, - hasWarnings, - summary, - }; -}; - -/** - * Gets a human-readable description of the font status - */ -export const getFontStatusDescription = (status: FontStatus): string => { - switch (status) { - case "perfect": - return "Fully embedded - perfect reproduction"; - case "embedded-subset": - return "Embedded (subset) - existing text will render correctly"; - case "system-fallback": - return "Using system font - appearance may differ"; - case "missing": - return "Not embedded - will use fallback font"; - case "unknown": - return "Unknown status"; - } -}; - -/** - * Gets a color indicator for the font status - */ -export const getFontStatusColor = (status: FontStatus): string => { - switch (status) { - case "perfect": - return "green"; - case "embedded-subset": - return "blue"; - case "system-fallback": - return "yellow"; - case "missing": - return "red"; - case "unknown": - return "gray"; - } -}; diff --git a/frontend/editor/src/core/tools/pdfTextEditor/hooks/useAutoLoadFile.ts b/frontend/editor/src/core/tools/pdfTextEditor/hooks/useAutoLoadFile.ts new file mode 100644 index 0000000000..14d5447f21 --- /dev/null +++ b/frontend/editor/src/core/tools/pdfTextEditor/hooks/useAutoLoadFile.ts @@ -0,0 +1,145 @@ +import { useCallback, useEffect, useMemo, useRef } from "react"; +import { useAllFiles, useFileSelection } from "@app/contexts/FileContext"; +import type { FileId } from "@app/types/file"; +import { useNavigationState } from "@app/contexts/NavigationContext"; +import { useViewer } from "@app/contexts/ViewerContext"; + +type Loader = (file: File) => unknown; +// The workbench fileId is what lets save write the edit back to the +// same file rather than only producing a download. +type OnFileChosen = (name: string, fileId?: FileId) => void; + +type WorkbenchFile = File & { fileId?: FileId; quickKey?: string }; + +function fileKey(file: File): string { + const f = file as WorkbenchFile; + return f.fileId ?? f.quickKey ?? `${f.name}|${f.size}|${f.lastModified}`; +} + +interface AutoLoad { + /** Open a workbench file deliberately. */ + openFile: (file: File) => void; + /** Record a document the editor loaded by other means, so auto-open stands down. */ + adopt: (file: File) => void; +} + +/** The slice of the editor's state that decides whether it needs a file. */ +export interface EditorLoadState { + hasDocument: boolean; + loading: boolean; + error: string | null; +} + +/** + * Open the file the user most likely wants. + * + * Auto-opening only ever fires while the editor holds nothing: the selection + * moves on its own (the Active Files view trims a multi-file selection down to + * its last entry to honour the tool's one-file limit), and following it would + * swap the open document, and any unsaved edits, out from under the user. + * + * "Holds nothing" is the store's own state, not a memory of having opened + * something. The store is a module singleton that drops its document when the + * canvas unmounts, while this hook's refs belong to the panel - so the two + * disagree whenever one outlives the other, and a hook that stood down on its + * own memory left the editor empty with no way back in. + */ +export function useAutoLoadFile( + load: Loader, + onFileChosen: OnFileChosen, + currentFileId: FileId | null, + /** Saving is swapping the workbench file under us; do not re-pick mid-swap. */ + hold: boolean, + /** The editor's live state, so "is a document open" is asked, not remembered. */ + editor: EditorLoadState, +): AutoLoad { + const navigationState = useNavigationState(); + const { selectedFiles } = useFileSelection(); + const { files: allFiles } = useAllFiles(); + const { activeFileId } = useViewer(); + + const autoLoadFile = useMemo(() => { + // Prefer the open document while it is still selected so a reordering + // selection cannot nudge the editor onto a different file. + if (currentFileId) { + const held = selectedFiles.find( + (f) => (f as WorkbenchFile).fileId === currentFileId, + ); + if (held) return held; + } + if (selectedFiles[0]) return selectedFiles[0]; + if (activeFileId) { + const viewerFile = allFiles.find( + (f) => (f as WorkbenchFile).fileId === activeFileId, + ); + if (viewerFile) return viewerFile; + } + if (allFiles.length === 1) return allFiles[0]; + return null; + }, [selectedFiles, activeFileId, allFiles, currentFileId]); + + // The open document left the workbench, so the editor is free to pick again. + const documentGone = + currentFileId != null && + !allFiles.some((f) => (f as WorkbenchFile).fileId === currentFileId); + + const lastKeyRef = useRef(null); + const adopt = useCallback((file: File) => { + lastKeyRef.current = fileKey(file); + }, []); + const openFile = useCallback( + (file: File) => { + adopt(file); + onFileChosen(file.name, (file as WorkbenchFile).fileId); + void load(file); + }, + [adopt, load, onFileChosen], + ); + + useEffect(() => { + if (!autoLoadFile || hold) return; + if (navigationState.selectedTool !== "pdfTextEditor") return; + // A document is open: leave it, and the user's unsaved edits, alone. + if (editor.hasDocument && !documentGone) return; + // An open is already in flight; landing it is what clears hasDocument. + if (editor.loading) return; + + // Recovery: the store dropped a document this hook had already opened. + // Re-open THAT file, and do it quietly - no pin, no filename change. The + // canvas can be dropped because the user went to Active Files, and pinning + // it back would yank them out of the list they just asked for. + const recovering = lastKeyRef.current !== null; + if (recovering) { + const same = allFiles.find( + (f) => (f as WorkbenchFile).fileId === currentFileId, + ); + // Nothing to recover to: the file left the workbench, so fall through + // and pick a candidate the normal way. + if (same) { + if (editor.error && lastKeyRef.current === fileKey(same)) return; + adopt(same); + void load(same); + return; + } + } + + // This exact file already failed to open. Retrying it is a loop, not a fix. + if (editor.error && lastKeyRef.current === fileKey(autoLoadFile)) return; + openFile(autoLoadFile); + }, [ + autoLoadFile, + documentGone, + editor.error, + editor.hasDocument, + editor.loading, + hold, + navigationState.selectedTool, + openFile, + adopt, + allFiles, + currentFileId, + load, + ]); + + return useMemo(() => ({ openFile, adopt }), [openFile, adopt]); +} diff --git a/frontend/editor/src/core/tools/pdfTextEditor/hooks/useDevicePixelRatio.ts b/frontend/editor/src/core/tools/pdfTextEditor/hooks/useDevicePixelRatio.ts new file mode 100644 index 0000000000..672f1b3f7d --- /dev/null +++ b/frontend/editor/src/core/tools/pdfTextEditor/hooks/useDevicePixelRatio.ts @@ -0,0 +1,34 @@ +import { useEffect, useState } from "react"; + +/** + * The display's current devicePixelRatio, live. A `(resolution: Xdppx)` media + * query matches exactly one ratio, so each change re-arms a fresh query - + * that is what keeps the value tracking when the window moves to a monitor + * with a different scale factor, or the user changes browser zoom. + */ +export function useDevicePixelRatio(): number { + const [dpr, setDpr] = useState(() => + typeof window === "undefined" ? 1 : window.devicePixelRatio || 1, + ); + + useEffect(() => { + if (typeof window.matchMedia !== "function") return; + let query: MediaQueryList | null = null; + let disposed = false; + const arm = () => { + if (disposed) return; + const current = window.devicePixelRatio || 1; + setDpr(current); + query?.removeEventListener("change", arm); + query = window.matchMedia(`(resolution: ${current}dppx)`); + query.addEventListener("change", arm); + }; + arm(); + return () => { + disposed = true; + query?.removeEventListener("change", arm); + }; + }, []); + + return dpr; +} diff --git a/frontend/editor/src/core/tools/pdfTextEditor/hooks/useDocumentLoader.ts b/frontend/editor/src/core/tools/pdfTextEditor/hooks/useDocumentLoader.ts new file mode 100644 index 0000000000..5472cfb825 --- /dev/null +++ b/frontend/editor/src/core/tools/pdfTextEditor/hooks/useDocumentLoader.ts @@ -0,0 +1,179 @@ +import { useCallback } from "react"; +import { EditorDocument } from "@app/tools/pdfTextEditor/model/EditorDocument"; +import { PdfiumTextReader } from "@app/tools/pdfTextEditor/pdfium/PdfiumTextReader"; +import { + FPDF_ERR_PASSWORD, + PdfiumOpenError, +} from "@app/services/pdfiumService"; +import type { EditorStore } from "@app/tools/pdfTextEditor/store/EditorStore"; +import type { PageSnapshot } from "@app/tools/pdfTextEditor/types"; + +const EAGER_PAGE_LIMIT = 5; + +/** Yield to the event loop so the React layer can paint progress. */ +const yieldToBrowser = () => + new Promise((resolve) => setTimeout(resolve, 0)); + +/** Open a PDF in PDFium and lazily populate pages on first visibility. */ +export function useDocumentLoader(store: EditorStore) { + return useCallback( + async (file: File, password?: string): Promise => { + // Each load claims a token. + const token = store.beginLoad(); + store.setLoading(true); + store.setProgress({ + stage: `Reading ${file.name}`, + current: 0, + total: 0, + }); + try { + await yieldToBrowser(); + const bytes = new Uint8Array(await file.arrayBuffer()); + if (!store.isCurrentLoad(token)) return; + store.setProgress({ + stage: "Parsing PDF", + current: 0, + total: 0, + }); + await yieldToBrowser(); + const doc = await EditorDocument.open(bytes, password); + if (!store.isCurrentLoad(token)) { + // A newer load superseded us before we installed our doc - free + // it ourselves (setDocument never took ownership). + try { + doc.dispose(); + } catch { + /* best-effort */ + } + return; + } + await store.setDocument(doc); + + const total = doc.pageCount; + const eager = Math.min(EAGER_PAGE_LIMIT, total); + const snapshots: PageSnapshot[] = []; + for (let i = 0; i < eager; i++) { + store.setProgress({ + stage: `Reading page ${i + 1} of ${total}`, + current: i, + total, + }); + await yieldToBrowser(); + // The check + synchronous read below run in one tick, so a + // superseding load can only interpose here. + if (!store.isCurrentLoad(token)) return; + const page = doc.page(i); + PdfiumTextReader.populate(doc, page, store.groupingMode); + snapshots.push({ + pageIndex: i, + width: page.width, + height: page.height, + dirty: false, + revision: page.revision, + runs: page.runs.map((r) => r.snapshot()), + images: page.images.map((img) => img.snapshot()), + annotations: page.annotations, + display: page.display.toData(), + }); + } + for (let i = eager; i < total; i++) { + const page = doc.page(i); + snapshots.push({ + pageIndex: i, + width: page.width, + height: page.height, + dirty: false, + revision: 0, + runs: [], + images: [], + display: page.display.toData(), + }); + } + if (!store.isCurrentLoad(token)) return; + store.publishPages(snapshots); + store.setProgress({ + stage: "Ready", + current: total, + total, + }); + } catch (err) { + if (store.isCurrentLoad(token)) { + // A password-protected PDF isn't a hard error. + if ( + err instanceof PdfiumOpenError && + err.code === FPDF_ERR_PASSWORD + ) { + store.setPasswordRequired(file, password !== undefined); + } else { + store.setError(err instanceof Error ? err.message : String(err)); + } + } + } finally { + // Only the winning load owns the loading/progress UI state. + if (store.isCurrentLoad(token)) { + store.setLoading(false); + store.setProgress(null); + } + } + }, + [store], + ); +} + +/** Read EVERY not-yet-loaded page in one pass and publish once. */ +export function ensureAllPagesRead(store: EditorStore): void { + const doc = store.document; + if (!doc) return; + let any = false; + for (const p of store.getState().pages) { + const page = doc.page(p.pageIndex); + if (page.loaded) continue; + try { + // Lazy reads must surface failures like the eager path, not throw out of the observer. + PdfiumTextReader.populate(doc, page, store.groupingMode); + any = true; + } catch (err) { + store.setError(err instanceof Error ? err.message : String(err)); + } + } + if (!any) return; + const next = store.getState().pages.map((p) => { + const page = doc.page(p.pageIndex); + return { + ...p, + revision: page.revision, + runs: page.runs.map((r) => r.snapshot()), + images: page.images.map((img) => img.snapshot()), + annotations: page.annotations, + }; + }); + store.publishPages(next); +} + +/** Ensure a page's runs/images are loaded. */ +export function ensurePageRead(store: EditorStore, pageIndex: number): void { + const doc = store.document; + if (!doc) return; + const page = doc.page(pageIndex); + if (page.loaded) return; + try { + // Lazy reads must surface failures like the eager path, not throw out of the observer. + PdfiumTextReader.populate(doc, page, store.groupingMode); + } catch (err) { + store.setError(err instanceof Error ? err.message : String(err)); + return; + } + const state = store.getState(); + const next = state.pages.map((p) => + p.pageIndex === pageIndex + ? { + ...p, + revision: page.revision, + runs: page.runs.map((r) => r.snapshot()), + images: page.images.map((img) => img.snapshot()), + annotations: page.annotations, + } + : p, + ); + store.publishPages(next); +} diff --git a/frontend/editor/src/core/tools/pdfTextEditor/hooks/useEditorClipboard.ts b/frontend/editor/src/core/tools/pdfTextEditor/hooks/useEditorClipboard.ts new file mode 100644 index 0000000000..7ecafbdc10 --- /dev/null +++ b/frontend/editor/src/core/tools/pdfTextEditor/hooks/useEditorClipboard.ts @@ -0,0 +1,175 @@ +import { useEffect, useRef } from "react"; +import { isFocusInContentEditable } from "@app/tools/pdfTextEditor/util/dom"; + +export interface EditorClipboardCallbacks { + /** True when any run or image is selected (images cut without carrying text). */ + hasSelection: () => boolean; + /** Text of the selected runs, or null when the selection carries none. */ + getSelectedText: () => string | null; + deleteSelection: () => void; + insertPastedText: (text: string, stripFormatting: boolean) => void; +} + +const SINK_ID = "pdf-editor-clipboard-sink"; + +/** The off-screen textarea, created on first use. */ +function ensureSink(): HTMLTextAreaElement { + let sink = document.getElementById(SINK_ID) as HTMLTextAreaElement | null; + if (!sink) { + sink = document.createElement("textarea"); + sink.id = SINK_ID; + sink.tabIndex = -1; + sink.setAttribute("aria-hidden", "true"); + sink.style.cssText = + "position:fixed;top:0;left:-9999px;width:1px;height:1px;padding:0;border:0;opacity:0;"; + document.body.appendChild(sink); + } + return sink; +} + +function getSink(): HTMLTextAreaElement | null { + return document.getElementById(SINK_ID) as HTMLTextAreaElement | null; +} + +/** Object-level cut/copy/paste for the editor. */ +export function useEditorClipboard(cbs: EditorClipboardCallbacks) { + const ref = useRef(cbs); + ref.current = cbs; + + useEffect(() => { + // ClipboardEvent carries no modifier state, so Ctrl+Shift+V is remembered + // from the keystroke that triggered it. + let pastePlain = false; + // Set by the native `cut` of the sink - i.e. proof the browser actually + // took the text to the system clipboard. + let sinkCutObserved = false; + // Deferred cleanup for the in-flight clipboard keystroke. + let pendingRelease: (() => void) | null = null; + let pendingTimer: ReturnType | null = null; + + /** Finish the previous clipboard keystroke NOW. */ + function flushPending(): void { + if (pendingTimer !== null) clearTimeout(pendingTimer); + pendingTimer = null; + const run = pendingRelease; + pendingRelease = null; + run?.(); + } + + // Runs after the keystroke's default action, so the browser's own + // cut/copy/paste of the sink has already happened. + function scheduleRelease(fn: () => void): void { + pendingRelease = fn; + pendingTimer = setTimeout(() => { + pendingTimer = null; + pendingRelease = null; + fn(); + }, 0); + } + + /** Empty the sink and hand focus back to whatever had it. */ + function releaseSink(restoreTo: HTMLElement | null): void { + const sink = getSink(); + if (!sink) return; + sink.value = ""; + if (document.activeElement !== sink) return; + // blur() first: focus() on is a no-op, so without this the sink + // keeps focus and the next keystroke is treated as an in-run edit. + sink.blur(); + if (restoreTo && restoreTo !== document.body) { + restoreTo.focus?.({ preventScroll: true }); + } + } + + function onCut(e: ClipboardEvent): void { + if (e.target === getSink()) sinkCutObserved = true; + } + + function onPaste(e: ClipboardEvent) { + // Consume the modifier state captured by the keystroke that opened this + // paste, whoever ends up handling it. + const stripFormatting = pastePlain; + pastePlain = false; + const sink = getSink(); + // Our own sink IS an editable element, so the guard below would eat the + // very paste we set it up to receive. + const intoSink = + sink !== null && (e.target === sink || document.activeElement === sink); + // A caret inside a run (or in Find/Replace/password) keeps native paste. + if (!intoSink && isFocusInContentEditable()) return; + const text = e.clipboardData?.getData("text/plain"); + if (!text) return; + e.preventDefault(); + ref.current.insertPastedText(text, stripFormatting); + } + + function onKeyDown(e: KeyboardEvent) { + if (!e.ctrlKey && !e.metaKey) return; + const key = e.key.toLowerCase(); + if (key === "v") { + // Recorded before any bail, or Ctrl+Shift+V would leave the flag set + // for whatever pastes next. + pastePlain = e.shiftKey; + // A caret inside a run (or in Find/Replace/password) pastes natively + // into that field - don't pull focus out from under it. + if (isFocusInContentEditable()) return; + flushPending(); + const restoreTo = document.activeElement as HTMLElement | null; + const sink = ensureSink(); + sink.value = ""; + // Synchronous, and deliberately NOT preventDefault: the keystroke's own + // default action is the paste. + sink.focus({ preventScroll: true }); + scheduleRelease(() => { + // No paste arrived (empty clipboard, image-only, engine declined): + // drop the modifier state so it can't leak into the next paste. + pastePlain = false; + releaseSink(restoreTo); + }); + return; + } + if (key !== "c" && key !== "x") return; + // A caret inside a run (or in Find/Replace/password) keeps native + // copy/cut over its own text. + if (isFocusInContentEditable()) return; + if (!ref.current.hasSelection()) return; + flushPending(); + const text = ref.current.getSelectedText(); + const restoreTo = document.activeElement as HTMLElement | null; + sinkCutObserved = false; + // Deliberately NOT preventDefault: the browser's own copy/cut of the + // sink's selection is what reaches the system clipboard. + if (text !== null) { + const sink = ensureSink(); + sink.value = text; + sink.focus({ preventScroll: true }); + sink.select(); + } + scheduleRelease(() => { + // Read the evidence before releaseSink() wipes the sink. + const clipboardWritten = sinkCutObserved; + releaseSink(restoreTo); + window.getSelection()?.removeAllRanges(); + // Only destroy the selection once the text is safely on the clipboard. + if (key === "x" && (text === null || clipboardWritten)) { + ref.current.deleteSelection(); + } + }); + } + + window.addEventListener("cut", onCut); + window.addEventListener("paste", onPaste); + window.addEventListener("keydown", onKeyDown); + return () => { + // Drop the pending release rather than flushing it: a cut's + // deleteSelection() must not fire into a tree that is unmounting. + if (pendingTimer !== null) clearTimeout(pendingTimer); + pendingTimer = null; + pendingRelease = null; + window.removeEventListener("cut", onCut); + window.removeEventListener("paste", onPaste); + window.removeEventListener("keydown", onKeyDown); + document.getElementById(SINK_ID)?.remove(); + }; + }, []); +} diff --git a/frontend/editor/src/core/tools/pdfTextEditor/hooks/useEditorKeyboardShortcuts.ts b/frontend/editor/src/core/tools/pdfTextEditor/hooks/useEditorKeyboardShortcuts.ts new file mode 100644 index 0000000000..d88d93e407 --- /dev/null +++ b/frontend/editor/src/core/tools/pdfTextEditor/hooks/useEditorKeyboardShortcuts.ts @@ -0,0 +1,185 @@ +import { useEffect } from "react"; +import type { EditorStore } from "@app/tools/pdfTextEditor/store/EditorStore"; +import { + findVisiblePageIndex, + isFocusInContentEditable, + isFocusInFormField, + pageElements, +} from "@app/tools/pdfTextEditor/util/dom"; + +interface KeyboardShortcutCallbacks { + store: EditorStore; + onUndo: () => void; + onRedo: () => void; + onSave: () => void; + onDelete: () => void; + onDuplicate: () => void; + onSelectAll: () => void; + onToggleHelp: () => void; + onOpenFind: () => void; + onFindNext: (reverse: boolean) => void; + onEscape: () => void; + onMergeSelection: () => void; +} + +/** Bind every editor-level keyboard shortcut to `window` for the session. */ +export function useEditorKeyboardShortcuts(cbs: KeyboardShortcutCallbacks) { + const { + store, + onUndo, + onRedo, + onSave, + onDelete, + onDuplicate, + onSelectAll, + onToggleHelp, + onOpenFind, + onFindNext, + onEscape, + onMergeSelection, + } = cbs; + + useEffect(() => { + function onMetaKey(e: KeyboardEvent) { + const meta = e.ctrlKey || e.metaKey; + if (!meta) return; + // Normalise: with Shift or CapsLock the letter arrives UPPERCASE. + switch (e.key.toLowerCase()) { + case "z": + // Form fields (Find/Replace/password) keep their NATIVE undo. + if (isFocusInFormField()) return; + // Blur an active editable before history so the overlay sync + // effect can rewrite the DOM from the reverted model. + if (isFocusInContentEditable()) + (document.activeElement as HTMLElement | null)?.blur(); + if (e.shiftKey) { + e.preventDefault(); + onRedo(); + } else { + e.preventDefault(); + onUndo(); + } + return; + case "y": + if (isFocusInFormField()) return; + if (isFocusInContentEditable()) + (document.activeElement as HTMLElement | null)?.blur(); + e.preventDefault(); + onRedo(); + return; + case "s": + e.preventDefault(); + // Commit the in-progress edit first: blur bakes the pending + // text + wrap reflow, otherwise the download misses them. + if (isFocusInContentEditable()) + (document.activeElement as HTMLElement | null)?.blur(); + onSave(); + return; + case "d": + // No focus guard: duplicate must work while a run's editable is + // focused. + e.preventDefault(); + if (store.selection.value.runIds.length === 0) return; + onDuplicate(); + return; + case "a": + // Guard covers contenteditable AND Find/password inputs (dom.ts). + if (isFocusInContentEditable()) return; + e.preventDefault(); + onSelectAll(); + return; + // c / x / v are deliberately NOT handled here. + case "f": + e.preventDefault(); + onOpenFind(); + return; + case "g": + e.preventDefault(); + onFindNext(e.shiftKey); + return; + case "m": + if (store.selection.value.runIds.length < 2) return; + e.preventDefault(); + if (isFocusInContentEditable()) + (document.activeElement as HTMLElement | null)?.blur(); + onMergeSelection(); + return; + default: + return; + } + } + + function onPlainKey(e: KeyboardEvent) { + if (e.ctrlKey || e.metaKey || e.altKey) return; + if (e.key === "?" || e.key === "F1") { + if (isFocusInContentEditable()) return; + e.preventDefault(); + onToggleHelp(); + return; + } + if (e.key === "F3") { + e.preventDefault(); + onFindNext(e.shiftKey); + return; + } + if (e.key === "Escape") { + if (isFocusInContentEditable()) return; + e.preventDefault(); + onEscape(); + return; + } + if (e.key === "Delete") { + if (isFocusInContentEditable()) return; + const sel = store.selection.value; + if (sel.runIds.length === 0 && sel.imageIds.length === 0) return; + e.preventDefault(); + onDelete(); + return; + } + } + + function onPageNav(e: KeyboardEvent) { + if (isFocusInContentEditable()) return; + const isHome = e.key === "Home" && (e.ctrlKey || e.metaKey); + const isEnd = e.key === "End" && (e.ctrlKey || e.metaKey); + if (e.key !== "PageDown" && e.key !== "PageUp" && !isHome && !isEnd) { + return; + } + if (store.getState().pageCount === 0) return; + const pages = pageElements(); + if (pages.length === 0) return; + const current = findVisiblePageIndex(); + let target = current; + if (e.key === "PageDown") + target = Math.min(pages.length - 1, current + 1); + else if (e.key === "PageUp") target = Math.max(0, current - 1); + else if (isHome) target = 0; + else if (isEnd) target = pages.length - 1; + if (target === current) return; + e.preventDefault(); + pages[target]?.scrollIntoView({ behavior: "smooth", block: "start" }); + } + + window.addEventListener("keydown", onMetaKey); + window.addEventListener("keydown", onPlainKey); + window.addEventListener("keydown", onPageNav); + return () => { + window.removeEventListener("keydown", onMetaKey); + window.removeEventListener("keydown", onPlainKey); + window.removeEventListener("keydown", onPageNav); + }; + }, [ + store, + onUndo, + onRedo, + onSave, + onDelete, + onDuplicate, + onSelectAll, + onToggleHelp, + onOpenFind, + onFindNext, + onEscape, + onMergeSelection, + ]); +} diff --git a/frontend/editor/src/core/tools/pdfTextEditor/hooks/useEditorStore.ts b/frontend/editor/src/core/tools/pdfTextEditor/hooks/useEditorStore.ts new file mode 100644 index 0000000000..ec847d9b2e --- /dev/null +++ b/frontend/editor/src/core/tools/pdfTextEditor/hooks/useEditorStore.ts @@ -0,0 +1,59 @@ +import { useEffect, useMemo, useState } from "react"; +import { EditorStore } from "@app/tools/pdfTextEditor/store/EditorStore"; + +let __singleton: EditorStore | null = null; +let __disposeTimer: ReturnType | null = null; + +// The store is a module-level singleton, so a hot update to EditorStore.ts +// swaps the CLASS but leaves this instance - built from the old code - running. +// Every timing constant and method on it stays as it was, which makes a fix look +// like it changed nothing. Take the full reload instead. +if (import.meta.hot) { + import.meta.hot.accept(() => { + window.location.reload(); + }); +} + +/** Grace period before a fully-unmounted editor frees its PDFium document. */ +const DISPOSE_GRACE_MS = 1500; + +/** Returns the singleton editor store, plus the current view state. */ +export function useEditorStore(): { + store: EditorStore; + state: ReturnType; +} { + const store = useMemo(() => { + if (!__singleton) __singleton = new EditorStore(); + return __singleton; + }, []); + const [state, setState] = useState(store.getState()); + useEffect(() => { + // A pending disposal means we just remounted within the grace window + // (StrictMode / sidebar toggle) - cancel it so the open doc survives. + if (__disposeTimer) { + clearTimeout(__disposeTimer); + __disposeTimer = null; + } + setState(store.getState()); + const unsubscribe = store.subscribe(setState); + return () => { + unsubscribe(); + // Defer disposal: if the component remounts (the effect above runs again) + // the timer is cancelled. + if (__disposeTimer) clearTimeout(__disposeTimer); + __disposeTimer = setTimeout(() => { + __disposeTimer = null; + __singleton?.clearDocument(); + }, DISPOSE_GRACE_MS); + }; + }, [store]); + return { store, state }; +} + +/** Test-only - drop the singleton so the next mount starts fresh. */ +export function __resetEditorStoreForTests(): void { + if (__singleton) { + __singleton.dispose(); + __singleton = null; + } +} diff --git a/frontend/editor/src/core/tools/pdfTextEditor/hooks/useEditorTestGlobal.ts b/frontend/editor/src/core/tools/pdfTextEditor/hooks/useEditorTestGlobal.ts new file mode 100644 index 0000000000..c7b47783ab --- /dev/null +++ b/frontend/editor/src/core/tools/pdfTextEditor/hooks/useEditorTestGlobal.ts @@ -0,0 +1,14 @@ +import { useEffect } from "react"; +import type { EditorStore } from "@app/tools/pdfTextEditor/store/EditorStore"; + +const KEY = "__editor_store"; + +/** Expose the editor store on `window` for Playwright. */ +export function useEditorTestGlobal(store: EditorStore): void { + useEffect(() => { + (window as unknown as Record)[KEY] = store; + return () => { + delete (window as unknown as Record)[KEY]; + }; + }, [store]); +} diff --git a/frontend/editor/src/core/tools/pdfTextEditor/hooks/useSelectionActions.ts b/frontend/editor/src/core/tools/pdfTextEditor/hooks/useSelectionActions.ts new file mode 100644 index 0000000000..a2f5da5e42 --- /dev/null +++ b/frontend/editor/src/core/tools/pdfTextEditor/hooks/useSelectionActions.ts @@ -0,0 +1,276 @@ +import { useCallback } from "react"; +import { DeleteImageCommand } from "@app/tools/pdfTextEditor/commands/DeleteImageCommand"; +import { ReplaceImageCommand } from "@app/tools/pdfTextEditor/commands/ReplaceImageCommand"; +import type { DecodedImage } from "@app/utils/pdfiumBitmapUtils"; +import { DeleteObjectCommand } from "@app/tools/pdfTextEditor/commands/DeleteObjectCommand"; +import { DuplicateRunCommand } from "@app/tools/pdfTextEditor/commands/DuplicateRunCommand"; +import { SetColourCommand } from "@app/tools/pdfTextEditor/commands/SetColourCommand"; +import { SetTextOutlineCommand } from "@app/tools/pdfTextEditor/commands/SetTextOutlineCommand"; +import { SetFontFamilyCommand } from "@app/tools/pdfTextEditor/commands/SetFontFamilyCommand"; +import { SetFontSizeCommand } from "@app/tools/pdfTextEditor/commands/SetFontSizeCommand"; +import { parseCssColor } from "@app/tools/pdfTextEditor/model/Color"; +import { ensureDeviceFontReady } from "@app/tools/pdfTextEditor/util/deviceFontEmbed"; +import type { EditorStore } from "@app/tools/pdfTextEditor/store/EditorStore"; +import { isItalicFamily } from "@app/tools/pdfTextEditor/util/fontFamily"; +import { italicCapability } from "@app/tools/pdfTextEditor/util/fontCapability"; +import { loadedLocalFonts } from "@app/tools/pdfTextEditor/util/localFonts"; +import { CompositeCommand } from "@app/tools/pdfTextEditor/commands/CompositeCommand"; +import type { Command } from "@app/tools/pdfTextEditor/commands/Command"; + +/** The fields the selection actions read off a run they are about to change. */ +interface SelectedRun { + id: string; + pageIndex: number; + fontId: string; + fill: { r: number; g: number; b: number; a: number }; +} + +/** Bundle of callbacks that operate on the current selection. */ +export function useSelectionActions(store: EditorStore) { + const forEachSelectedRun = useCallback( + (visit: (run: SelectedRun) => void) => { + const sel = store.selection.value; + const doc = store.document; + if (!doc || sel.runIds.length === 0) return; + // Pre-index the selection for O(1) membership in the nested walk. + const selIds = new Set(sel.runIds); + for (const page of doc.loadedPages()) { + for (const run of page.runs) { + // Locked runs are selectable but must not mutate. + if (selIds.has(run.id) && !run.locked) visit(run); + } + } + }, + [store], + ); + + // One command per run, dispatched as ONE undo step - same reason + // `deleteSelection` groups its deletes. Select-all now reaches the whole + // document, so a per-run dispatch left the user hundreds of undos behind and + // the first Ctrl+Z looked like the restyle had only covered part of the file. + const dispatchPerRun = useCallback( + (build: (run: SelectedRun) => Command | null) => { + const cmds: Command[] = []; + forEachSelectedRun((run) => { + const cmd = build(run); + if (cmd) cmds.push(cmd); + }); + if (cmds.length === 1) store.dispatch(cmds[0]); + else if (cmds.length > 1) store.dispatch(new CompositeCommand(cmds)); + }, + [store, forEachSelectedRun], + ); + + const changeFontSize = useCallback( + (size: number) => { + dispatchPerRun( + (run) => + new SetFontSizeCommand({ + pageIndex: run.pageIndex, + runId: run.id, + nextSize: size, + }), + ); + }, + [dispatchPerRun], + ); + + const changeFill = useCallback( + (hex: string) => { + const fill = parseCssColor(hex); + if (!fill) return; + dispatchPerRun( + (run) => + new SetColourCommand({ + pageIndex: run.pageIndex, + runId: run.id, + // The picker edits RGB only; keep each run's OWN alpha so + // recolouring semi-transparent text doesn't force it opaque. + nextFill: { ...fill, a: run.fill.a }, + }), + ); + }, + [dispatchPerRun], + ); + + const changeOutline = useCallback( + (hex: string | null, width: number) => { + const stroke = hex ? parseCssColor(hex) : null; + dispatchPerRun( + (run) => + new SetTextOutlineCommand({ + pageIndex: run.pageIndex, + runId: run.id, + stroke: stroke ? { ...stroke, a: 255 } : null, + width, + }), + ); + }, + [dispatchPerRun], + ); + + const changeFontFamily = useCallback( + async (family: string) => { + // Embedding is async and Command.apply is not, so warm the bytes first. + // A no-op for the built-in families. + await ensureDeviceFontReady(family); + dispatchPerRun( + (run) => + new SetFontFamilyCommand({ + pageIndex: run.pageIndex, + runId: run.id, + nextFamily: family, + }), + ); + }, + [dispatchPerRun], + ); + + const toggleItalic = useCallback(async () => { + const fonts = loadedLocalFonts(); + const targets: Array<{ + pageIndex: number; + runId: string; + family: string; + device: boolean; + }> = []; + forEachSelectedRun((run) => { + const cap = italicCapability( + run.fontId, + !isItalicFamily(run.fontId), + fonts, + ); + // No real italic cut for this face. Leave it alone: swapping the + // document's own font for Helvetica-Oblique is not making it italic. + if (!cap.family) return; + targets.push({ + pageIndex: run.pageIndex, + runId: run.id, + family: cap.family, + device: cap.source === "device", + }); + }); + // Embedding is async and Command.apply is not, so warm the bytes first. + for (const target of targets) { + if (target.device) await ensureDeviceFontReady(target.family); + } + const cmds = targets.map( + (target) => + new SetFontFamilyCommand({ + pageIndex: target.pageIndex, + runId: target.runId, + nextFamily: target.family, + }), + ); + if (cmds.length === 1) store.dispatch(cmds[0]); + else if (cmds.length > 1) store.dispatch(new CompositeCommand(cmds)); + }, [store, forEachSelectedRun]); + + const deleteSelection = useCallback(() => { + const sel = store.selection.value; + const doc = store.document; + if (!doc) return; + if (sel.runIds.length === 0 && sel.imageIds.length === 0) return; + // Collect one command per object but dispatch them as ONE composite: a + // 30-object delete must be a single undo step, not 30. + const cmds: Array = []; + for (const page of doc.loadedPages()) { + for (const run of page.runs) { + if (sel.runIds.includes(run.id) && !run.locked) { + cmds.push( + new DeleteObjectCommand({ + pageIndex: run.pageIndex, + runId: run.id, + }), + ); + } + } + for (const img of page.images) { + if (sel.imageIds.includes(img.id) && !img.locked) { + cmds.push( + new DeleteImageCommand({ + pageIndex: img.pageIndex, + imageId: img.id, + }), + ); + } + } + } + if (cmds.length === 1) store.dispatch(cmds[0]); + else if (cmds.length > 1) store.dispatch(new CompositeCommand(cmds)); + store.selection.clear(); + }, [store]); + + const replaceImageById = useCallback( + ( + pageIndex: number, + imageId: string, + image: DecodedImage, + jpegBytes?: Uint8Array, + ) => { + const doc = store.document; + if (!doc) return; + // By id, not the live selection: an external edit can land long after + // the user selected something else, or opened another document. + const page = doc.loadedPages().find((p) => p.index === pageIndex); + const img = page?.images.find((i) => i.id === imageId); + if (!img || img.locked) return; + store.dispatch( + new ReplaceImageCommand({ + pageIndex: img.pageIndex, + imageId: img.id, + image, + jpegBytes, + }), + ); + }, + [store], + ); + + const replaceSelectedImage = useCallback( + (image: DecodedImage, jpegBytes?: Uint8Array) => { + const sel = store.selection.value; + if (sel.imageIds.length !== 1) return; + const doc = store.document; + const img = doc + ?.loadedPages() + .flatMap((p) => p.images) + .find((i) => i.id === sel.imageIds[0]); + if (!img) return; + replaceImageById(img.pageIndex, img.id, image, jpegBytes); + }, + [store, replaceImageById], + ); + + const duplicateFirstSelected = useCallback(() => { + const sel = store.selection.value; + if (sel.runIds.length === 0) return; + const doc = store.document; + if (!doc) return; + const targetId = sel.runIds[0]; + for (const page of doc.loadedPages()) { + for (const r of page.runs) { + if (r.id !== targetId) continue; + const cmd = new DuplicateRunCommand({ + pageIndex: r.pageIndex, + runId: targetId, + }); + store.dispatch(cmd); + if (cmd.insertedRunId) store.selection.selectOne(cmd.insertedRunId); + return; + } + } + }, [store]); + + return { + changeFontSize, + changeFill, + changeOutline, + changeFontFamily, + toggleItalic, + deleteSelection, + replaceSelectedImage, + replaceImageById, + duplicateFirstSelected, + }; +} diff --git a/frontend/editor/src/core/tools/pdfTextEditor/hooks/useSelectionGeometry.ts b/frontend/editor/src/core/tools/pdfTextEditor/hooks/useSelectionGeometry.ts new file mode 100644 index 0000000000..3ecbadf038 --- /dev/null +++ b/frontend/editor/src/core/tools/pdfTextEditor/hooks/useSelectionGeometry.ts @@ -0,0 +1,110 @@ +import { useMemo } from "react"; +import { MoveTextRunCommand } from "@app/tools/pdfTextEditor/commands/MoveTextRunCommand"; +import { ReflowWrapCommand } from "@app/tools/pdfTextEditor/commands/ReflowWrapCommand"; +import { SetImageTransformCommand } from "@app/tools/pdfTextEditor/commands/SetImageTransformCommand"; +import type { EditorStore } from "@app/tools/pdfTextEditor/store/EditorStore"; +import type { EditorViewState } from "@app/tools/pdfTextEditor/store/EditorStore"; +import type { PageRect, SelectionState } from "@app/tools/pdfTextEditor/types"; + +export interface SingleSelectionGeometry { + bounds: PageRect; + setX: (next: number) => void; + setY: (next: number) => void; + setWidth: (next: number) => void; + /** Absent for text runs: their height follows the type, not a handle. */ + setHeight?: (next: number) => void; +} + +export interface SelectionGeometry { + /** Null unless exactly one object is selected. */ + single: SingleSelectionGeometry | null; +} + +/** + * Numeric position/size for the inspector, in PDF points. + * + * Only meaningful for a single object - the fields would have to invent a + * value for a mixed selection, so the panel shows a hint instead. + */ +export function useSelectionGeometry( + store: EditorStore, + state: EditorViewState, + selection: SelectionState, +): SelectionGeometry { + return useMemo(() => { + const runId = selection.runIds[0]; + const imageId = selection.imageIds[0]; + const total = selection.runIds.length + selection.imageIds.length; + if (total !== 1) return { single: null }; + + if (runId) { + for (const page of state.pages) { + const run = page.runs.find((r) => r.id === runId); + if (!run) continue; + const pageIndex = page.pageIndex; + const bounds = run.bounds; + return { + single: { + bounds, + setX: (next) => + store.dispatch( + new MoveTextRunCommand({ + pageIndex, + runId, + dx: next - bounds.x, + dy: 0, + }), + ), + setY: (next) => + store.dispatch( + new MoveTextRunCommand({ + pageIndex, + runId, + dx: 0, + dy: next - bounds.y, + }), + ), + // Narrowing a run is exactly the wrap gesture, so it reuses the + // same command the canvas resize handle drives. + setWidth: (next) => + store.dispatch( + new ReflowWrapCommand({ + pageIndex, + runId, + maxWidthPt: Math.max(1, next), + }), + ), + }, + }; + } + return { single: null }; + } + + if (imageId) { + for (const page of state.pages) { + const img = page.images.find((i) => i.id === imageId); + if (!img) continue; + const pageIndex = page.pageIndex; + const bounds = img.bounds; + const set = (patch: Partial) => + store.dispatch( + new SetImageTransformCommand({ + pageIndex, + imageId, + nextBounds: { ...bounds, ...patch }, + }), + ); + return { + single: { + bounds, + setX: (next) => set({ x: next }), + setY: (next) => set({ y: next }), + setWidth: (next) => set({ width: Math.max(1, next) }), + setHeight: (next) => set({ height: Math.max(1, next) }), + }, + }; + } + } + return { single: null }; + }, [store, state.pages, selection]); +} diff --git a/frontend/editor/src/core/tools/pdfTextEditor/hooks/useToolbarController.ts b/frontend/editor/src/core/tools/pdfTextEditor/hooks/useToolbarController.ts new file mode 100644 index 0000000000..e32b0aa042 --- /dev/null +++ b/frontend/editor/src/core/tools/pdfTextEditor/hooks/useToolbarController.ts @@ -0,0 +1,529 @@ +import { + useCallback, + useEffect, + useMemo, + useRef, + useSyncExternalStore, +} from "react"; +import { useSelectionActions } from "@app/tools/pdfTextEditor/hooks/useSelectionActions"; +import { deriveToolbarState } from "@app/tools/pdfTextEditor/util/toolbarState"; +import { warmDocumentDeviceFonts } from "@app/tools/pdfTextEditor/util/fontCapability"; +import { + loadedLocalFonts, + subscribeLocalFonts, +} from "@app/tools/pdfTextEditor/util/localFonts"; +import { + ChangeZOrderCommand, + type ZOrderMode, +} from "@app/tools/pdfTextEditor/commands/ChangeZOrderCommand"; +import { EditTextCommand } from "@app/tools/pdfTextEditor/commands/EditTextCommand"; +import { CompositeCommand } from "@app/tools/pdfTextEditor/commands/CompositeCommand"; +import { MoveTextRunCommand } from "@app/tools/pdfTextEditor/commands/MoveTextRunCommand"; +import { SetImageTransformCommand } from "@app/tools/pdfTextEditor/commands/SetImageTransformCommand"; +import { SetLockCommand } from "@app/tools/pdfTextEditor/commands/SetLockCommand"; +import { AlignParagraphLinesCommand } from "@app/tools/pdfTextEditor/commands/AlignParagraphLinesCommand"; +import { + TransformImageObjectCommand, + type ImageTransformMode, +} from "@app/tools/pdfTextEditor/commands/TransformImageObjectCommand"; +import type { EditorStore } from "@app/tools/pdfTextEditor/store/EditorStore"; +import type { EditorViewState } from "@app/tools/pdfTextEditor/store/EditorStore"; +import type { SelectionState } from "@app/tools/pdfTextEditor/types"; +import { + isExternalImageEditSupported, + startExternalImageEdit, + type ExternalEditWatch, +} from "@app/tools/pdfTextEditor/util/externalImageEdit"; +import { + decodeBytesForEmbed, + decodeImageForEmbed, + pickImageFile, +} from "@app/tools/pdfTextEditor/util/imagePicking"; +import { readImageObjectPixels } from "@app/tools/pdfTextEditor/util/imagePixels"; + +type AlignMode = "left" | "center-h" | "right" | "top" | "middle-v" | "bottom"; + +// Everything the contextual `Toolbar` needs, derived from the shared +// `EditorStore`. +export function useToolbarController( + store: EditorStore, + state: EditorViewState, + selection: SelectionState, +) { + const sel = useSelectionActions(store); + + const onToggleLock = useCallback(() => { + const doc = store.document; + if (!doc) return; + const selRuns = new Set(store.selection.value.runIds); + const selImages = new Set(store.selection.value.imageIds); + if (selRuns.size === 0 && selImages.size === 0) return; + let allLocked = true; + for (const p of doc.loadedPages()) { + for (const r of p.runs) + if (selRuns.has(r.id) && !r.locked) allLocked = false; + for (const im of p.images) + if (selImages.has(im.id) && !im.locked) allLocked = false; + } + const nextLocked = !allLocked; + for (const p of doc.loadedPages()) { + for (const r of p.runs) + if (selRuns.has(r.id) && r.locked !== nextLocked) { + store.dispatch( + new SetLockCommand({ + pageIndex: p.index, + runId: r.id, + locked: nextLocked, + }), + ); + } + for (const im of p.images) + if (selImages.has(im.id) && im.locked !== nextLocked) { + store.dispatch( + new SetLockCommand({ + pageIndex: p.index, + imageId: im.id, + locked: nextLocked, + }), + ); + } + } + }, [store]); + + const onChangeZOrder = useCallback( + (mode: ZOrderMode) => { + const doc = store.document; + if (!doc) return; + const selRuns = new Set(store.selection.value.runIds); + const selImages = new Set(store.selection.value.imageIds); + if (selRuns.size === 0 && selImages.size === 0) return; + for (const p of doc.loadedPages()) { + for (const r of p.runs) { + if (!selRuns.has(r.id)) continue; + store.dispatch( + new ChangeZOrderCommand({ pageIndex: p.index, runId: r.id, mode }), + ); + } + for (const im of p.images) { + if (!selImages.has(im.id)) continue; + store.dispatch( + new ChangeZOrderCommand({ + pageIndex: p.index, + imageId: im.id, + mode, + }), + ); + } + } + }, + [store], + ); + + const onAlign = useCallback( + (mode: AlignMode) => { + const doc = store.document; + if (!doc) return; + const selRuns = new Set(store.selection.value.runIds); + const selImages = new Set(store.selection.value.imageIds); + // Single multi-line paragraph + a horizontal mode: align the lines + // WITHIN that paragraph instead of requiring a 2+ object selection. + if ( + selRuns.size === 1 && + selImages.size === 0 && + (mode === "left" || mode === "center-h" || mode === "right") + ) { + const runId = [...selRuns][0]; + for (const p of doc.loadedPages()) { + const run = p.runs.find((r) => r.id === runId); + if (!run) continue; + if (AlignParagraphLinesCommand.canAlign(run)) { + store.dispatch( + new AlignParagraphLinesCommand({ + pageIndex: p.index, + runId, + mode, + }), + ); + } + return; + } + } + if (selRuns.size + selImages.size < 2) return; + // One gesture must be one undo step, not one per object. + const moves: Array = []; + for (const p of doc.loadedPages()) { + const items: Array<{ + kind: "run" | "image"; + id: string; + bounds: { x: number; y: number; width: number; height: number }; + }> = []; + // Locked objects stay selectable but must never be moved, the + // same rule every other bulk path applies. + for (const r of p.runs) { + if (!selRuns.has(r.id) || r.locked) continue; + items.push({ kind: "run", id: r.id, bounds: r.bounds }); + } + for (const im of p.images) { + if (!selImages.has(im.id) || im.locked) continue; + items.push({ kind: "image", id: im.id, bounds: im.bounds }); + } + if (items.length < 2) continue; + const lefts = items.map((it) => it.bounds.x); + const rights = items.map((it) => it.bounds.x + it.bounds.width); + const bottoms = items.map((it) => it.bounds.y); + const tops = items.map((it) => it.bounds.y + it.bounds.height); + const minLeft = Math.min(...lefts); + const maxRight = Math.max(...rights); + const minBottom = Math.min(...bottoms); + const maxTop = Math.max(...tops); + const centreX = (minLeft + maxRight) / 2; + const centreY = (minBottom + maxTop) / 2; + for (const it of items) { + const b = it.bounds; + let dx = 0; + let dy = 0; + switch (mode) { + case "left": + dx = minLeft - b.x; + break; + case "right": + dx = maxRight - (b.x + b.width); + break; + case "center-h": + dx = centreX - (b.x + b.width / 2); + break; + case "bottom": + dy = minBottom - b.y; + break; + case "top": + dy = maxTop - (b.y + b.height); + break; + case "middle-v": + dy = centreY - (b.y + b.height / 2); + break; + } + if (Math.abs(dx) < 0.01 && Math.abs(dy) < 0.01) continue; + if (it.kind === "run") { + moves.push( + new MoveTextRunCommand({ + pageIndex: p.index, + runId: it.id, + dx, + dy, + }), + ); + } else { + moves.push( + new SetImageTransformCommand({ + pageIndex: p.index, + imageId: it.id, + nextBounds: { + x: b.x + dx, + y: b.y + dy, + width: b.width, + height: b.height, + }, + }), + ); + } + } + } + if (moves.length === 1) store.dispatch(moves[0]); + else if (moves.length > 1) store.dispatch(new CompositeCommand(moves)); + }, + [store], + ); + + const onDistribute = useCallback( + (axis: "horizontal" | "vertical") => { + const doc = store.document; + if (!doc) return; + const selRuns = new Set(store.selection.value.runIds); + const selImages = new Set(store.selection.value.imageIds); + if (selRuns.size + selImages.size < 3) return; + // One gesture must be one undo step, not one per object. + const moves: Array = []; + for (const p of doc.loadedPages()) { + const items: Array<{ + kind: "run" | "image"; + id: string; + bounds: { x: number; y: number; width: number; height: number }; + }> = []; + // Locked objects stay selectable but must never be moved, the + // same rule every other bulk path applies. + for (const r of p.runs) { + if (!selRuns.has(r.id) || r.locked) continue; + items.push({ kind: "run", id: r.id, bounds: r.bounds }); + } + for (const im of p.images) { + if (!selImages.has(im.id) || im.locked) continue; + items.push({ kind: "image", id: im.id, bounds: im.bounds }); + } + if (items.length < 3) continue; + items.sort((a, b) => + axis === "horizontal" + ? a.bounds.x - b.bounds.x + : a.bounds.y - b.bounds.y, + ); + const first = items[0].bounds; + const last = items[items.length - 1].bounds; + const totalSize = + axis === "horizontal" + ? last.x + last.width - first.x + : last.y + last.height - first.y; + const sumSize = items.reduce( + (acc, it) => + acc + (axis === "horizontal" ? it.bounds.width : it.bounds.height), + 0, + ); + const gap = (totalSize - sumSize) / (items.length - 1); + let cursor = + axis === "horizontal" + ? first.x + first.width + gap + : first.y + first.height + gap; + for (let i = 1; i < items.length - 1; i++) { + const it = items[i]; + const b = it.bounds; + let dx = 0; + let dy = 0; + if (axis === "horizontal") { + dx = cursor - b.x; + cursor += b.width + gap; + } else { + dy = cursor - b.y; + cursor += b.height + gap; + } + if (Math.abs(dx) < 0.01 && Math.abs(dy) < 0.01) continue; + if (it.kind === "run") { + moves.push( + new MoveTextRunCommand({ + pageIndex: p.index, + runId: it.id, + dx, + dy, + }), + ); + } else { + moves.push( + new SetImageTransformCommand({ + pageIndex: p.index, + imageId: it.id, + nextBounds: { + x: b.x + dx, + y: b.y + dy, + width: b.width, + height: b.height, + }, + }), + ); + } + } + } + if (moves.length === 1) store.dispatch(moves[0]); + else if (moves.length > 1) store.dispatch(new CompositeCommand(moves)); + }, + [store], + ); + + const onTransformImage = useCallback( + (mode: ImageTransformMode) => { + const doc = store.document; + if (!doc) return; + const selImages = new Set(store.selection.value.imageIds); + if (selImages.size === 0) return; + for (const p of doc.loadedPages()) { + for (const im of p.images) { + if (!selImages.has(im.id)) continue; + store.dispatch( + new TransformImageObjectCommand({ + pageIndex: p.index, + imageId: im.id, + mode, + }), + ); + } + } + }, + [store], + ); + + const onChangeCase = useCallback( + (mode: "upper" | "lower" | "title" | "sentence") => { + const doc = store.document; + if (!doc) return; + const selIds = new Set(store.selection.value.runIds); + if (selIds.size === 0) return; + const transform = (s: string): string => { + switch (mode) { + case "upper": + return s.toUpperCase(); + case "lower": + return s.toLowerCase(); + case "title": + // \p{L}/u instead of \b\w: ASCII word chars mis-cased accented + // and non-Latin letters ("elan" with acute became "eLan"). + return s.replace( + /(^|[^\p{L}\p{N}'])([\p{L}\p{N}][\p{L}\p{N}']*)/gu, + (_m, sep: string, w: string) => + sep + w[0].toUpperCase() + w.slice(1).toLowerCase(), + ); + case "sentence": + return s.replace(/(^\s*\p{L}|[.!?]\s+\p{L})/gu, (m) => + m.toUpperCase(), + ); + } + }; + // One composite = one undo step for the whole selection, and locked + // runs are exempt like every other bulk mutation. + const cmds: EditTextCommand[] = []; + for (const p of doc.loadedPages()) { + for (const r of p.runs) { + if (!selIds.has(r.id) || r.locked) continue; + const next = transform(r.text); + if (next === r.text) continue; + cmds.push( + new EditTextCommand({ + pageIndex: p.index, + runId: r.id, + nextText: next, + }), + ); + } + } + if (cmds.length === 1) store.dispatch(cmds[0]); + else if (cmds.length > 1) store.dispatch(new CompositeCommand(cmds)); + }, + [store], + ); + + // Null until the user loads their device fonts, and it must re-render when + // they do - the italic control's availability is derived from it. + const localFonts = useSyncExternalStore( + subscribeLocalFonts, + loadedLocalFonts, + loadedLocalFonts, + ); + + const documentFontIds = useMemo( + () => [...new Set(state.pages.flatMap((p) => p.runs.map((r) => r.fontId)))], + [state.pages], + ); + + // Match the document's own families against the installed ones, so an edit + // that outgrows an embedded subset completes from the real face. + useEffect(() => { + if (!localFonts) return; + void warmDocumentDeviceFonts(documentFontIds); + }, [localFonts, documentFontIds]); + + const toolbarState = useMemo( + () => deriveToolbarState(state.pages, selection, localFonts), + [state.pages, selection, localFonts], + ); + + const selectionAllLocked = useMemo(() => { + const runs = new Set(selection.runIds); + const images = new Set(selection.imageIds); + if (runs.size === 0 && images.size === 0) return false; + for (const p of state.pages) { + for (const r of p.runs) if (runs.has(r.id) && !r.locked) return false; + for (const im of p.images) + if (images.has(im.id) && !im.locked) return false; + } + return true; + }, [state.pages, selection]); + + const canAlignLines = useMemo(() => { + if (selection.runIds.length !== 1 || selection.imageIds.length > 0) + return false; + const run = state.pages + .flatMap((p) => p.runs) + .find((r) => r.id === selection.runIds[0]); + // Mirrors AlignParagraphLinesCommand.canAlign, which gates on SLOTS: line + // count enabled the item for paragraphs the command then refused. + return !!run && (run.paragraphSlotCount ?? 0) >= 2; + }, [state.pages, selection]); + + const onReplaceImage = useCallback(() => { + void (async () => { + const file = await pickImageFile(); + if (!file) return; + try { + const picked = await decodeImageForEmbed(file); + sel.replaceSelectedImage(picked.decoded, picked.jpegBytes); + } catch (err) { + store.setError(err instanceof Error ? err.message : String(err)); + } + })(); + }, [sel, store]); + + const watchRef = useRef(null); + useEffect(() => () => watchRef.current?.stop(), []); + + const onEditImageExternally = useCallback(() => { + void (async () => { + const doc = store.document; + const imageId = selection.imageIds[0]; + if (!doc || !imageId) return; + const target = doc + .loadedPages() + .flatMap((page) => page.images) + .find((img) => img.id === imageId); + if (!target?.pdfiumObjPtr) return; + const pixels = readImageObjectPixels( + doc, + target.pageIndex, + target.pdfiumObjPtr, + ); + if (!pixels) return; + // Only one image can be watched at a time; starting a second replaces + // the first rather than leaving two pollers racing over the document. + watchRef.current?.stop(); + const outcome = await startExternalImageEdit({ + pixels, + suggestedName: "pdf-image.png", + onChange: (bytes) => { + void decodeBytesForEmbed(bytes) + .then((decoded) => + sel.replaceImageById(target.pageIndex, imageId, decoded), + ) + .catch(() => undefined); + }, + }); + if (outcome.status === "watching") watchRef.current = outcome.watch; + })(); + }, [sel, selection.imageIds, store]); + + return { + state: toolbarState, + canUndo: store.history.canUndo, + canRedo: store.history.canRedo, + onUndo: () => store.undo(), + onRedo: () => store.redo(), + onChangeFontSize: sel.changeFontSize, + onChangeFill: sel.changeFill, + onChangeOutline: sel.changeOutline, + onChangeFontFamily: (family: string) => { + void sel.changeFontFamily(family); + }, + onToggleItalic: sel.toggleItalic, + onDelete: sel.deleteSelection, + onToggleLock, + onChangeCase, + onChangeZOrder, + onAlign, + onDistribute, + onTransformImage, + onReplaceImage, + onEditImageExternally, + externalEditSupported: isExternalImageEditSupported(), + selectionAllLocked, + hasRunSelection: selection.runIds.length > 0, + hasImageSelection: selection.imageIds.length > 0, + selectionCount: selection.runIds.length + selection.imageIds.length, + canAlignLines, + disabled: + !state.hasDocument || + (selection.runIds.length === 0 && selection.imageIds.length === 0), + }; +} diff --git a/frontend/editor/src/core/tools/pdfTextEditor/hooks/useUnsavedChangesGuard.ts b/frontend/editor/src/core/tools/pdfTextEditor/hooks/useUnsavedChangesGuard.ts new file mode 100644 index 0000000000..be3d80fdbd --- /dev/null +++ b/frontend/editor/src/core/tools/pdfTextEditor/hooks/useUnsavedChangesGuard.ts @@ -0,0 +1,31 @@ +import { useEffect } from "react"; +import { useNavigationActions } from "@app/contexts/NavigationContext"; + +// Guard unsaved edits on BOTH exit routes. +// +// `beforeunload` only covers a full-page unload (tab close / reload / external +// navigation). Switching tools inside the SPA never triggers it, so on its own +// this hook let the editor drop every edit silently. NavigationContext is the +// app's own in-app guard - it is what PageEditor uses - and it drives +// NavigationWarningModal. +export function useUnsavedChangesGuard(dirty: boolean): void { + const { actions } = useNavigationActions(); + const setHasUnsavedChanges = actions.setHasUnsavedChanges; + + useEffect(() => { + if (!dirty) return; + const handler = (e: BeforeUnloadEvent) => { + e.preventDefault(); + e.returnValue = ""; + }; + window.addEventListener("beforeunload", handler); + return () => window.removeEventListener("beforeunload", handler); + }, [dirty]); + + useEffect(() => { + setHasUnsavedChanges(dirty); + // Clear on unmount so a stale flag cannot block navigation after the + // editor is gone. + return () => setHasUnsavedChanges(false); + }, [dirty, setHasUnsavedChanges]); +} diff --git a/frontend/editor/src/core/tools/pdfTextEditor/hooks/useWorkbenchPin.ts b/frontend/editor/src/core/tools/pdfTextEditor/hooks/useWorkbenchPin.ts new file mode 100644 index 0000000000..e621c67581 --- /dev/null +++ b/frontend/editor/src/core/tools/pdfTextEditor/hooks/useWorkbenchPin.ts @@ -0,0 +1,92 @@ +import { useCallback, useEffect, useRef } from "react"; +import { + useNavigationActions, + useNavigationState, +} from "@app/contexts/NavigationContext"; +import { useToolWorkflow } from "@app/contexts/ToolWorkflowContext"; +import type { CustomWorkbenchViewRegistration } from "@app/contexts/ToolWorkflowContext"; + +interface PinOptions { + workbenchId: CustomWorkbenchViewRegistration["workbenchId"]; + workbenchViewId: string; + label: string; + icon: React.ReactNode; + component: CustomWorkbenchViewRegistration["component"]; +} + +// Register the custom workbench view and open it when the editor tool is +// selected. Returns a `pin` that brings the canvas back on demand. +export function useWorkbenchPin({ + workbenchId, + workbenchViewId, + label, + icon, + component, +}: PinOptions): () => void { + const { + registerCustomWorkbenchView, + unregisterCustomWorkbenchView, + setCustomWorkbenchViewData, + clearCustomWorkbenchViewData, + setLeftPanelView, + } = useToolWorkflow(); + const { actions: navigationActions } = useNavigationActions(); + const navigationState = useNavigationState(); + + // Stash the per-render values that aren't dependable identities so the effect + // can read them on mount without re-running on every parent render. + const viewRef = useRef({ + workbenchId, + workbenchViewId, + label, + icon, + component, + }); + viewRef.current = { workbenchId, workbenchViewId, label, icon, component }; + useEffect(() => { + const v = viewRef.current; + registerCustomWorkbenchView({ + id: v.workbenchViewId, + workbenchId: v.workbenchId, + label: v.label, + icon: v.icon, + component: v.component, + }); + setCustomWorkbenchViewData(v.workbenchViewId, { kind: "pdfTextEditor" }); + setLeftPanelView("toolContent"); + return () => { + clearCustomWorkbenchViewData(v.workbenchViewId); + unregisterCustomWorkbenchView(v.workbenchViewId); + }; + }, [ + registerCustomWorkbenchView, + unregisterCustomWorkbenchView, + setCustomWorkbenchViewData, + clearCustomWorkbenchViewData, + setLeftPanelView, + ]); + + const actionsRef = useRef(navigationActions); + actionsRef.current = navigationActions; + + const pin = useCallback(() => { + actionsRef.current.setWorkbench(workbenchId); + }, [workbenchId]); + + // Open the canvas once, when the tool is picked. Re-pinning on every + // workbench change would bounce the user straight back here the moment they + // switch to Active Files to choose a different file. + const pinnedRef = useRef(false); + useEffect(() => { + if (navigationState.selectedTool !== "pdfTextEditor") { + pinnedRef.current = false; + return; + } + if (pinnedRef.current) return; + pinnedRef.current = true; + if (navigationState.workbench === workbenchId) return; + actionsRef.current.setWorkbench(workbenchId); + }, [navigationState.selectedTool, navigationState.workbench, workbenchId]); + + return pin; +} diff --git a/frontend/editor/src/core/tools/pdfTextEditor/model/AnnotationBox.ts b/frontend/editor/src/core/tools/pdfTextEditor/model/AnnotationBox.ts new file mode 100644 index 0000000000..6c164854b8 --- /dev/null +++ b/frontend/editor/src/core/tools/pdfTextEditor/model/AnnotationBox.ts @@ -0,0 +1,22 @@ +/** Text-carrying annotation that the editor renders but cannot edit. */ + +/** PDFium FPDF_ANNOTATION_SUBTYPE values the editor cares about. */ +export const ANNOT_SUBTYPE_FREETEXT = 3; +export const ANNOT_SUBTYPE_STAMP = 13; +export const ANNOT_SUBTYPE_WIDGET = 20; + +export type AnnotationKind = "freetext" | "widget" | "stamp"; + +export interface AnnotationBox { + id: string; + kind: AnnotationKind; + /** Raw PDF page-space rect (y-up), pre-DisplayTransform. */ + rect: { x: number; y: number; width: number; height: number }; +} + +export function annotationKindFor(subtype: number): AnnotationKind | null { + if (subtype === ANNOT_SUBTYPE_FREETEXT) return "freetext"; + if (subtype === ANNOT_SUBTYPE_WIDGET) return "widget"; + if (subtype === ANNOT_SUBTYPE_STAMP) return "stamp"; + return null; +} diff --git a/frontend/editor/src/core/tools/pdfTextEditor/model/Color.ts b/frontend/editor/src/core/tools/pdfTextEditor/model/Color.ts new file mode 100644 index 0000000000..b11c1dcf55 --- /dev/null +++ b/frontend/editor/src/core/tools/pdfTextEditor/model/Color.ts @@ -0,0 +1,46 @@ +import type { RGBA } from "@app/tools/pdfTextEditor/types"; + +export const BLACK: RGBA = { r: 0, g: 0, b: 0, a: 255 }; +export const WHITE: RGBA = { r: 255, g: 255, b: 255, a: 255 }; + +/** Parse a `#rrggbb`, `#rrggbbaa`, or `rgb(...)` string. Returns null on failure. */ +export function parseCssColor(value: string): RGBA | null { + const trimmed = value.trim(); + if (trimmed.startsWith("#")) { + const hex = trimmed.slice(1); + if (hex.length === 6 || hex.length === 8) { + const r = parseInt(hex.slice(0, 2), 16); + const g = parseInt(hex.slice(2, 4), 16); + const b = parseInt(hex.slice(4, 6), 16); + const a = hex.length === 8 ? parseInt(hex.slice(6, 8), 16) : 255; + if ([r, g, b, a].every((c) => Number.isFinite(c))) { + return { r, g, b, a }; + } + } + return null; + } + const m = trimmed.match( + /^rgba?\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)(?:\s*,\s*(\d*\.?\d+))?\s*\)$/i, + ); + if (m) { + const r = Number(m[1]); + const g = Number(m[2]); + const b = Number(m[3]); + const a = m[4] === undefined ? 255 : Math.round(Number(m[4]) * 255); + return { r, g, b, a }; + } + return null; +} + +/** Format an RGBA as `#rrggbb` (ignoring alpha). */ +export function toCssHex(color: RGBA): string { + const hex = (n: number) => + Math.max(0, Math.min(255, Math.round(n))) + .toString(16) + .padStart(2, "0"); + return `#${hex(color.r)}${hex(color.g)}${hex(color.b)}`; +} + +export function equalsRGBA(a: RGBA, b: RGBA): boolean { + return a.r === b.r && a.g === b.g && a.b === b.b && a.a === b.a; +} diff --git a/frontend/editor/src/core/tools/pdfTextEditor/model/DisplayTransform.ts b/frontend/editor/src/core/tools/pdfTextEditor/model/DisplayTransform.ts new file mode 100644 index 0000000000..1435cb0b12 --- /dev/null +++ b/frontend/editor/src/core/tools/pdfTextEditor/model/DisplayTransform.ts @@ -0,0 +1,357 @@ +import type { WrappedPdfiumModule } from "@embedpdf/pdfium"; + +/** Maps a page's raw PDF object coordinates to "display-PDF" space. */ +export interface DisplayTransformData { + a: number; + b: number; + c: number; + d: number; + e: number; + f: number; + cropLeft: number; + cropBottom: number; + cropWidth: number; + cropHeight: number; + /** PDFium rotation quarter-turns clockwise: 0|1|2|3 (= 0/90/180/270 deg). */ + rotate: number; + /** Displayed page size in PDF points (rotation-applied; == page width/height). */ + displayWidth: number; + displayHeight: number; +} + +type BoxReader = ( + page: number, + left: number, + bottom: number, + right: number, + top: number, +) => number | boolean; + +interface CropBoxModule { + FPDFPage_GetCropBox?: BoxReader; + FPDFPage_GetMediaBox?: BoxReader; + FPDF_GetPageBoundingBox?: (page: number, rect: number) => number | boolean; + FPDFPage_GetRotation?: (page: number) => number; +} + +interface PageBox { + left: number; + bottom: number; + right: number; + top: number; +} + +export class DisplayTransform implements DisplayTransformData { + readonly a: number; + readonly b: number; + readonly c: number; + readonly d: number; + readonly e: number; + readonly f: number; + readonly cropLeft: number; + readonly cropBottom: number; + readonly cropWidth: number; + readonly cropHeight: number; + readonly rotate: number; + readonly displayWidth: number; + readonly displayHeight: number; + readonly isIdentity: boolean; + + constructor(d: DisplayTransformData) { + // Normalise -0 to 0 so identity coefficients compare cleanly (-0 === 0 is + // true, but Object.is / toEqual distinguish them). + const nz = (x: number): number => (x === 0 ? 0 : x); + this.a = nz(d.a); + this.b = nz(d.b); + this.c = nz(d.c); + this.d = nz(d.d); + this.e = nz(d.e); + this.f = nz(d.f); + this.cropLeft = d.cropLeft; + this.cropBottom = d.cropBottom; + this.cropWidth = d.cropWidth; + this.cropHeight = d.cropHeight; + this.rotate = d.rotate; + this.displayWidth = d.displayWidth; + this.displayHeight = d.displayHeight; + this.isIdentity = + this.a === 1 && + this.b === 0 && + this.c === 0 && + this.d === 1 && + this.e === 0 && + this.f === 0; + } + + /** Identity for a page of the given display size (CropBox==MediaBox, no rotate). */ + static identity( + displayWidth: number, + displayHeight: number, + ): DisplayTransform { + const dw = Number.isFinite(displayWidth) ? displayWidth : 0; + const dh = Number.isFinite(displayHeight) ? displayHeight : 0; + return new DisplayTransform({ + a: 1, + b: 0, + c: 0, + d: 1, + e: 0, + f: 0, + cropLeft: 0, + cropBottom: 0, + cropWidth: dw, + cropHeight: dh, + rotate: 0, + displayWidth: dw, + displayHeight: dh, + }); + } + + /** Reconstruct from the serializable plain-data shape (e.g. a PageSnapshot). */ + static fromData(d: DisplayTransformData): DisplayTransform { + return new DisplayTransform(d); + } + + toData(): DisplayTransformData { + return { + a: this.a, + b: this.b, + c: this.c, + d: this.d, + e: this.e, + f: this.f, + cropLeft: this.cropLeft, + cropBottom: this.cropBottom, + cropWidth: this.cropWidth, + cropHeight: this.cropHeight, + rotate: this.rotate, + displayWidth: this.displayWidth, + displayHeight: this.displayHeight, + }; + } + + /** Raw PDF point -> display-PDF point (y-up). */ + apply(px: number, py: number): { x: number; y: number } { + return { + x: this.a * px + this.c * py + this.e, + y: this.b * px + this.d * py + this.f, + }; + } + + /** Display-PDF point -> raw PDF point (inverse of apply). */ + invert(xd: number, yd: number): { x: number; y: number } { + const det = this.a * this.d - this.b * this.c; + if (det === 0) return { x: xd, y: yd }; + const ia = this.d / det; + const ib = -this.b / det; + const ic = -this.c / det; + const id = this.a / det; + const ie = -(ia * this.e + ic * this.f); + const iff = -(ib * this.e + id * this.f); + return { x: ia * xd + ic * yd + ie, y: ib * xd + id * yd + iff }; + } + + /** Raw direction vector -> display direction (linear part only, no translation). */ + applyVector(vx: number, vy: number): { x: number; y: number } { + return { x: this.a * vx + this.c * vy, y: this.b * vx + this.d * vy }; + } + + /** Display direction vector -> raw direction (inverse linear part only). */ + invertVector(vx: number, vy: number): { x: number; y: number } { + const det = this.a * this.d - this.b * this.c; + if (det === 0) return { x: vx, y: vy }; + const ia = this.d / det; + const ib = -this.b / det; + const ic = -this.c / det; + const id = this.a / det; + return { x: ia * vx + ic * vy, y: ib * vx + id * vy }; + } + + // Build the transform for a page by reading its CropBox + rotation from + // PDFium. + static fromPage( + m: WrappedPdfiumModule, + pagePtr: number, + displayWidth: number, + displayHeight: number, + ): DisplayTransform { + const mod = m as unknown as CropBoxModule; + const box = readBox(m, mod, pagePtr); + if (!box) return DisplayTransform.identity(displayWidth, displayHeight); + const rotate = callSafely( + () => (mod.FPDFPage_GetRotation?.(pagePtr) ?? 0) & 3, + 0, + ); + return DisplayTransform.fromCropAndRotate( + box.left, + box.bottom, + box.right - box.left, + box.top - box.bottom, + rotate, + displayWidth, + displayHeight, + ); + } + + // Pure constructor from CropBox extents + rotation (exposed for tests). + // `rotate` is quarter-turns clockwise (0..3). + static fromCropAndRotate( + cl: number, + cb: number, + cw: number, + ch: number, + rotate: number, + displayWidth: number, + displayHeight: number, + ): DisplayTransform { + const box = normaliseBox(cl, cb, cl + cw, cb + ch); + if (!box) return DisplayTransform.identity(displayWidth, displayHeight); + const left = box.left; + const bottom = box.bottom; + const width = box.right - box.left; + const height = box.top - box.bottom; + let a = 1, + b = 0, + c = 0, + d = 1, + e = -left, + f = -bottom; + switch (rotate & 3) { + case 0: + a = 1; + b = 0; + c = 0; + d = 1; + e = -left; + f = -bottom; + break; + case 1: // 90 CW - proper rotation (det +1), verified vs PDFium ground truth + a = 0; + b = -1; + c = 1; + d = 0; + e = -bottom; + f = width + left; + break; + case 2: // 180 + a = -1; + b = 0; + c = 0; + d = -1; + e = width + left; + f = height + bottom; + break; + case 3: // 270 CW - proper rotation (det +1), verified vs PDFium ground truth + a = 0; + b = 1; + c = -1; + d = 0; + e = height + bottom; + f = -left; + break; + } + return new DisplayTransform({ + a, + b, + c, + d, + e, + f, + cropLeft: left, + cropBottom: bottom, + cropWidth: width, + cropHeight: height, + rotate: rotate & 3, + displayWidth, + displayHeight, + }); + } +} + +function callSafely(run: () => T, fallback: T): T { + try { + return run(); + } catch { + return fallback; + } +} + +function boxOrNull( + left: number, + bottom: number, + right: number, + top: number, +): PageBox | null { + if ( + !Number.isFinite(left) || + !Number.isFinite(bottom) || + !Number.isFinite(right) || + !Number.isFinite(top) + ) { + return null; + } + if (right - left <= 0 || top - bottom <= 0) return null; + return { left, bottom, right, top }; +} + +function normaliseBox( + left: number, + bottom: number, + right: number, + top: number, +): PageBox | null { + return boxOrNull( + Math.min(left, right), + Math.min(bottom, top), + Math.max(left, right), + Math.max(bottom, top), + ); +} + +function intersectBoxes(a: PageBox, b: PageBox): PageBox | null { + return boxOrNull( + Math.max(a.left, b.left), + Math.max(a.bottom, b.bottom), + Math.min(a.right, b.right), + Math.min(a.top, b.top), + ); +} + +function readBox( + m: WrappedPdfiumModule, + mod: CropBoxModule, + pagePtr: number, +): PageBox | null { + const exports = m.pdfium.wasmExports as unknown as { + malloc: (n: number) => number; + free: (p: number) => void; + }; + const buf = exports.malloc(16); + if (!buf) return null; + try { + const slot = (i: number): number => m.pdfium.getValue(buf + i * 4, "float"); + const bounding = mod.FPDF_GetPageBoundingBox; + if (bounding) { + const ok = callSafely(() => !!bounding(pagePtr, buf), false); + const effective = ok + ? normaliseBox(slot(0), slot(3), slot(2), slot(1)) + : null; + if (effective) return effective; + } + const readRect = (fn?: BoxReader): PageBox | null => { + if (!fn) return null; + const ok = callSafely( + () => !!fn(pagePtr, buf, buf + 4, buf + 8, buf + 12), + false, + ); + if (!ok) return null; + return normaliseBox(slot(0), slot(1), slot(2), slot(3)); + }; + const crop = readRect(mod.FPDFPage_GetCropBox); + const media = readRect(mod.FPDFPage_GetMediaBox); + if (crop && media) return intersectBoxes(crop, media) ?? media; + return crop ?? media; + } finally { + exports.free(buf); + } +} diff --git a/frontend/editor/src/core/tools/pdfTextEditor/model/EditorDocument.ts b/frontend/editor/src/core/tools/pdfTextEditor/model/EditorDocument.ts new file mode 100644 index 0000000000..240e2dd7b8 --- /dev/null +++ b/frontend/editor/src/core/tools/pdfTextEditor/model/EditorDocument.ts @@ -0,0 +1,188 @@ +import type { WrappedPdfiumModule } from "@embedpdf/pdfium"; +import { + closeDocAndFreeBuffer, + getPdfiumModule, + openRawDocument, +} from "@app/services/pdfiumService"; +import { Page } from "@app/tools/pdfTextEditor/model/Page"; +import { DisplayTransform } from "@app/tools/pdfTextEditor/model/DisplayTransform"; +import { FontRef } from "@app/tools/pdfTextEditor/model/FontRef"; +import { prepareForEditing } from "@app/tools/pdfTextEditor/pdfdoc/prepareForEditing"; + +// Lifetime-managed PDFium document wrapper for the PDF text editor. - Opens a +// raw PDFium document pointer from bytes. +// Above this the save-time repairs are skipped rather than keeping a second +// full copy of the file alive for the session. +const MAX_RETAINED_BYTES = 64 * 1024 * 1024; +const EMPTY = new Uint8Array(0); + +export class EditorDocument { + readonly module: WrappedPdfiumModule; + readonly docPtr: number; + /** Exactly the bytes PDFium was handed: the save-time repairs re-read them. */ + /** Empty when the file was too large to keep a second copy of. */ + readonly openedBytes: Uint8Array; + private readonly pageCache: Map; + private readonly ownedFonts: Map; + private _disposed: boolean; + // Form-fill environment. Widgets with no appearance stream are drawn ONLY by + // this layer, so without it such fields are invisible in the editor while + // being visible everywhere else in the app. Created lazily and left null when + // the build lacks the entry points. + private formEnvPtr: number | null = null; + private formEnvTried = false; + private readonly formLoadedPages = new Set(); + + private constructor( + module: WrappedPdfiumModule, + docPtr: number, + openedBytes: Uint8Array, + ) { + this.module = module; + this.docPtr = docPtr; + this.openedBytes = openedBytes; + this.pageCache = new Map(); + this.ownedFonts = new Map(); + this._disposed = false; + } + + static async open( + data: ArrayBuffer | Uint8Array, + password?: string, + ): Promise { + const module = await getPdfiumModule(); + const bytes = data instanceof Uint8Array ? data : new Uint8Array(data); + const prepared = await prepareForEditing(bytes); + const docPtr = await openRawDocument(prepared, password); + // PDFium already holds its own heap copy, so retaining these doubles the + // footprint; past a point the gradient repair is not worth that. + const keep = prepared.length <= MAX_RETAINED_BYTES ? prepared : EMPTY; + return new EditorDocument(module, docPtr, keep); + } + + /** Page indices whose content stream has been regenerated this session. */ + regeneratedPages(): number[] { + return this.loadedPages() + .filter((p) => p.regenerated) + .map((p) => p.index); + } + + get pageCount(): number { + return this.module.FPDF_GetPageCount(this.docPtr); + } + + get disposed(): boolean { + return this._disposed; + } + + // Form-fill environment for this document, or null when unavailable. The + // caller must pair it with `notifyFormPageLoaded` before drawing a page. + formEnvironment(): number | null { + if (this.formEnvTried) return this.formEnvPtr; + this.formEnvTried = true; + const m = this.module as unknown as { + PDFiumExt_OpenFormFillInfo?: () => number; + PDFiumExt_InitFormFillEnvironment?: (doc: number, info: number) => number; + }; + if (!m.PDFiumExt_OpenFormFillInfo || !m.PDFiumExt_InitFormFillEnvironment) { + return null; + } + try { + const info = m.PDFiumExt_OpenFormFillInfo(); + const env = m.PDFiumExt_InitFormFillEnvironment(this.docPtr, info); + this.formEnvPtr = env || null; + } catch { + this.formEnvPtr = null; + } + return this.formEnvPtr; + } + + /** Tell the form layer about a page once, before its first form draw. */ + notifyFormPageLoaded(page: Page): void { + const env = this.formEnvironment(); + if (!env || this.formLoadedPages.has(page.pagePtr)) return; + const m = this.module as unknown as { + FORM_OnAfterLoadPage?: (pagePtr: number, env: number) => void; + }; + if (!m.FORM_OnAfterLoadPage) return; + try { + m.FORM_OnAfterLoadPage(page.pagePtr, env); + this.formLoadedPages.add(page.pagePtr); + } catch { + /* best-effort: the page still renders without the form layer */ + } + } + + page(index: number): Page { + const cached = this.pageCache.get(index); + if (cached) return cached; + const pagePtr = this.module.FPDF_LoadPage(this.docPtr, index); + if (!pagePtr) { + throw new Error(`EditorDocument: failed to load page ${index}`); + } + const width = this.module.FPDF_GetPageWidthF(pagePtr); + const height = this.module.FPDF_GetPageHeightF(pagePtr); + // CropBox/rotation transform for the screen boundary; identity for normal + // pages (CropBox==MediaBox, /Rotate==0) so behaviour is unchanged there. + const display = DisplayTransform.fromPage( + this.module, + pagePtr, + width, + height, + ); + const page = new Page({ index, pagePtr, width, height, display }); + this.pageCache.set(index, page); + return page; + } + + registerOwnedFont(font: FontRef): void { + this.ownedFonts.set(font.id, font); + } + + ownedFont(id: string): FontRef | undefined { + return this.ownedFonts.get(id); + } + + /** Iterate loaded pages without forcing more page loads. */ + loadedPages(): Page[] { + return Array.from(this.pageCache.values()); + } + + dispose(): void { + if (this._disposed) return; + this._disposed = true; + if (this.formEnvPtr) { + const m = this.module as unknown as { + FORM_OnBeforeClosePage?: (pagePtr: number, env: number) => void; + FPDFDOC_ExitFormFillEnvironment?: (env: number) => void; + }; + for (const pagePtr of this.formLoadedPages) { + try { + m.FORM_OnBeforeClosePage?.(pagePtr, this.formEnvPtr); + } catch { + /* best-effort */ + } + } + try { + m.FPDFDOC_ExitFormFillEnvironment?.(this.formEnvPtr); + } catch { + /* best-effort */ + } + this.formEnvPtr = null; + } + this.formLoadedPages.clear(); + for (const page of this.pageCache.values()) { + try { + this.module.FPDF_ClosePage(page.pagePtr); + } catch { + /* best-effort */ + } + } + this.pageCache.clear(); + for (const font of this.ownedFonts.values()) { + font.dispose(); + } + this.ownedFonts.clear(); + closeDocAndFreeBuffer(this.module, this.docPtr); + } +} diff --git a/frontend/editor/src/core/tools/pdfTextEditor/model/FontRef.ts b/frontend/editor/src/core/tools/pdfTextEditor/model/FontRef.ts new file mode 100644 index 0000000000..25bf8afd74 --- /dev/null +++ b/frontend/editor/src/core/tools/pdfTextEditor/model/FontRef.ts @@ -0,0 +1,32 @@ +import type { FontDescriptor } from "@app/tools/pdfTextEditor/types"; + +// A handle to a font inside a PDFium document. `pointer` is the FPDF_FONT +// handle. `owned` decides whether `dispose` should call `FPDFFont_Close`. +export class FontRef { + readonly id: string; + readonly descriptor: FontDescriptor; + readonly pointer: number; + private readonly owned: boolean; + private closeFn: ((ptr: number) => void) | null; + + constructor(opts: { + id: string; + descriptor: FontDescriptor; + pointer: number; + owned: boolean; + closeFn?: (ptr: number) => void; + }) { + this.id = opts.id; + this.descriptor = opts.descriptor; + this.pointer = opts.pointer; + this.owned = opts.owned; + this.closeFn = opts.closeFn ?? null; + } + + dispose(): void { + if (this.owned && this.closeFn && this.pointer) { + this.closeFn(this.pointer); + } + this.closeFn = null; + } +} diff --git a/frontend/editor/src/core/tools/pdfTextEditor/model/ImageObject.ts b/frontend/editor/src/core/tools/pdfTextEditor/model/ImageObject.ts new file mode 100644 index 0000000000..57e70c55f1 --- /dev/null +++ b/frontend/editor/src/core/tools/pdfTextEditor/model/ImageObject.ts @@ -0,0 +1,44 @@ +import type { + Affine, + ImageObjectSnapshot, + PageRect, +} from "@app/tools/pdfTextEditor/types"; + +export class ImageObject { + readonly id: string; + readonly pageIndex: number; + pdfiumObjPtr: number; + /** Owning form XObject, or 0 when the image sits on the page. */ + containerPtr: number; + bounds: PageRect; + matrix: Affine; + dirty: boolean; + /** Session-only lock; see TextRun.locked. */ + locked: boolean; + + constructor( + init: ImageObjectSnapshot & { + pdfiumObjPtr: number; + containerPtr?: number; + }, + ) { + this.id = init.id; + this.pageIndex = init.pageIndex; + this.pdfiumObjPtr = init.pdfiumObjPtr; + this.containerPtr = init.containerPtr ?? 0; + this.bounds = init.bounds; + this.matrix = init.matrix; + this.dirty = false; + this.locked = init.locked ?? false; + } + + snapshot(): ImageObjectSnapshot { + return { + id: this.id, + pageIndex: this.pageIndex, + bounds: { ...this.bounds }, + matrix: { ...this.matrix }, + locked: this.locked || undefined, + }; + } +} diff --git a/frontend/editor/src/core/tools/pdfTextEditor/model/Page.ts b/frontend/editor/src/core/tools/pdfTextEditor/model/Page.ts new file mode 100644 index 0000000000..7eab70b45b --- /dev/null +++ b/frontend/editor/src/core/tools/pdfTextEditor/model/Page.ts @@ -0,0 +1,117 @@ +import { TextRun } from "@app/tools/pdfTextEditor/model/TextRun"; +import { ImageObject } from "@app/tools/pdfTextEditor/model/ImageObject"; +import { DisplayTransform } from "@app/tools/pdfTextEditor/model/DisplayTransform"; +import type { AnnotationBox } from "@app/tools/pdfTextEditor/model/AnnotationBox"; +import type { WrappedPdfiumModule } from "@embedpdf/pdfium"; + +/** Wraps one PDFium page pointer. */ +export class Page { + readonly index: number; + readonly pagePtr: number; + readonly width: number; + readonly height: number; + // Maps this page's raw PDF object coords (MediaBox, y-up) to the rendered + // bitmap's display space (CropBox-cropped + /Rotate-applied). + readonly display: DisplayTransform; + runs: TextRun[]; + images: ImageObject[]; + /** Text-carrying annotations: rendered by the canvas, not editable. */ + annotations: AnnotationBox[]; + /** True if any object on this page has uncommitted mutation. */ + dirty: boolean; + /** True if the lazy reader has populated runs/images. */ + loaded: boolean; + /** Monotonic version counter, bumped on every commit. */ + revision: number; + // True when commands have mutated PDFium objects on this page but + // `FPDFPage_GenerateContent` hasn't been called yet. + needsGenerateContent: boolean; + // Sticky: regenerated at least once. Regeneration is what drops shadings, so + // the save-time repair needs this long after `dirty` was cleared. + regenerated: boolean; + + constructor(opts: { + index: number; + pagePtr: number; + width: number; + height: number; + display?: DisplayTransform; + }) { + this.index = opts.index; + this.pagePtr = opts.pagePtr; + this.width = opts.width; + this.height = opts.height; + this.display = + opts.display ?? DisplayTransform.identity(opts.width, opts.height); + this.runs = []; + this.images = []; + this.annotations = []; + this.dirty = false; + this.loaded = false; + this.revision = 0; + this.needsGenerateContent = false; + this.regenerated = false; + } + + setRuns(runs: TextRun[]): void { + this.runs = runs; + } + + setImages(images: ImageObject[]): void { + this.images = images; + } + + setAnnotations(annotations: AnnotationBox[]): void { + this.annotations = annotations; + } + + markDirty(): void { + this.dirty = true; + this.revision += 1; + } + + /** Bump the snapshot revision WITHOUT marking the page dirty. */ + bumpRevision(): void { + this.revision += 1; + } + + clearDirty(): void { + this.dirty = false; + this.runs.forEach((r) => { + r.dirty = false; + }); + this.images.forEach((i) => { + i.dirty = false; + }); + } + + // Record that this page's PDFium content stream is stale and needs a future + // GenerateContent before render or save. + markNeedsGenerate(): void { + this.needsGenerateContent = true; + } + + /** Run `FPDFPage_GenerateContent` if there are pending mutations. */ + flushGenerate(m: WrappedPdfiumModule): void { + if (!this.needsGenerateContent) return; + this.needsGenerateContent = false; + this.regenerated = true; + // PDFium reports regeneration failure by RETURN VALUE, not by throwing. + // Discarding it let a page that regenerated to nothing serialize its stale + // pre-edit stream while the UI reported a clean save. Throwing routes it + // into PdfiumSave's failedPages guard, which aborts the save. + if (!m.FPDFPage_GenerateContent(this.pagePtr)) { + throw new Error( + `FPDFPage_GenerateContent failed for page ${this.index + 1}`, + ); + } + } + + findRun(id: string): TextRun | undefined { + return this.runs.find((r) => r.id === id); + } + + findImage(id: string): ImageObject | undefined { + return this.images.find((i) => i.id === id); + } +} diff --git a/frontend/editor/src/core/tools/pdfTextEditor/model/TextRun.ts b/frontend/editor/src/core/tools/pdfTextEditor/model/TextRun.ts new file mode 100644 index 0000000000..cc0d3b2f1b --- /dev/null +++ b/frontend/editor/src/core/tools/pdfTextEditor/model/TextRun.ts @@ -0,0 +1,208 @@ +import type { + Affine, + PageRect, + RGBA, + TextRunSnapshot, +} from "@app/tools/pdfTextEditor/types"; + +/** One line's worth of sub-run data inside a paragraph. */ +export interface ParagraphLineSlot { + startChar: number; + endChar: number; + baselineY: number; + matrixE: number; + containerPtr: number; + fontId: string; + fontSize: number; + fontSubset: boolean; + mergedFromPtrs: number[]; + mergedFromTexts: string[]; + mergedFromBounds: Array<{ x: number; right: number }>; + /** Char-start positions RELATIVE to the line's text (0..lineText.length). */ + mergedFromCharStarts: number[]; +} + +/** Deep-clone a slot so the copy shares NO nested arrays with the source. */ +export function cloneParagraphLineSlot( + s: ParagraphLineSlot, +): ParagraphLineSlot { + return { + ...s, + mergedFromPtrs: [...s.mergedFromPtrs], + mergedFromTexts: [...s.mergedFromTexts], + mergedFromBounds: s.mergedFromBounds.map((b) => ({ ...b })), + mergedFromCharStarts: [...s.mergedFromCharStarts], + }; +} + +/** One PDF text object. */ +export class TextRun { + readonly id: string; + readonly pageIndex: number; + /** PDFium object pointer (page-relative). Zero means "newly created, not yet inserted". */ + pdfiumObjPtr: number; + bounds: PageRect; + matrix: Affine; + text: string; + fontId: string; + fontSize: number; + fill: RGBA; + fontSubset: boolean; + // PDF text render mode (Tr): 0 fill, 1/2 stroke variants, 3 invisible (OCR + // layers over scans), 4-7 clipping. + renderMode: number; + // Glyph outline (PDF stroke state), carried even when the render mode hides + // it, so a re-emit cannot silently drop an outlined heading's outline. + stroke: RGBA | null; + strokeWidth: number; + // Engine pen origins/ends per code unit of `text`, raw page points. Valid + // only while `charPositionsText` still equals `text`, so edits invalidate. + charStartsX: number[] | null; + charEndsX: number[] | null; + charPositionsKey: string | null; + /** Effective extra advance per glyph in PDF points. */ + charSpacingPt: number; + /** True when the run has uncommitted mutation. */ + dirty: boolean; + // If the LineGrouper merged multiple PDFium objects into this run, the + // original object pointers (in left-to-right order). + mergedFromPtrs: number[]; + /** Per-sub-run text (parallel to `mergedFromPtrs`). */ + mergedFromTexts: string[]; + /** Per-sub-run bounds (parallel to `mergedFromPtrs`). */ + mergedFromBounds: Array<{ x: number; right: number }>; + // Per-sub-run starting position in `run.text` (parallel to `mergedFromPtrs`). + mergedFromCharStarts: number[]; + // If this run was extracted from inside a form xobject, the PDFium pointer of + // the immediate parent form. + containerPtr: number; + /** If the run was extracted from a form xobject. */ + topLevelContainerPtr: number; + // When ParagraphGrouper merged multiple line groups into this run, the + // average vertical distance between consecutive baselines (in PDF points). + paragraphLineHeight: number; + /** PDFium pointers for each constituent line, top-down. */ + paragraphMemberPtrs: number[]; + /** Form-xobject containers (parallel array) for each member. */ + paragraphMemberContainers: number[]; + /** Baseline f-values for each member, top-down. */ + paragraphMemberFs: number[]; + // Every leaf PDFium pointer that backs this paragraph - includes each line's + // own `mergedFromPtrs` flattened. + paragraphLeafPtrs: number[]; + /** Parallel form-xobject containers for every leaf ptr. */ + paragraphLeafContainers: number[]; + // Pointer to the LATEST background cover-rect emitted on the page for this + // run. + coverRectPtr: number; + /** Per-line sub-run snapshots for paragraph-aware partial edits. */ + paragraphLineSlots: ParagraphLineSlot[]; + // Which visual lines start at a break the WRAP put there rather than one the + // user typed. run.text spells both as a newline - it has to, or the line + // count the painter and the box height read disagrees with the ink on the + // page - so the difference lives here. Without it a reflow re-reads its own + // soft breaks as forced ones and the paragraph can never re-flow again. + paragraphSoftStarts: boolean[]; + // Session-only lock: when true the run is skipped by all hit-tests (mouse, + // marquee, Ctrl+A) and edit gestures are no-ops. + locked: boolean; + + constructor( + init: TextRunSnapshot & { + pdfiumObjPtr: number; + containerPtr?: number; + topLevelContainerPtr?: number; + }, + ) { + this.id = init.id; + this.pageIndex = init.pageIndex; + this.pdfiumObjPtr = init.pdfiumObjPtr; + this.bounds = init.bounds; + this.matrix = init.matrix; + this.text = init.text; + this.fontId = init.fontId; + this.fontSize = init.fontSize; + this.fill = init.fill; + this.fontSubset = init.fontSubset; + this.renderMode = init.renderMode ?? 0; + this.stroke = init.stroke ?? null; + this.strokeWidth = init.strokeWidth ?? 0; + this.charStartsX = null; + this.charEndsX = null; + this.charPositionsKey = null; + this.charSpacingPt = 0; + this.dirty = false; + this.mergedFromPtrs = []; + this.mergedFromTexts = []; + this.mergedFromBounds = []; + this.mergedFromCharStarts = []; + this.containerPtr = init.containerPtr ?? 0; + this.topLevelContainerPtr = init.topLevelContainerPtr ?? 0; + this.paragraphLineHeight = 0; + this.paragraphMemberPtrs = []; + this.paragraphMemberContainers = []; + this.paragraphMemberFs = []; + this.paragraphLeafPtrs = []; + this.paragraphLeafContainers = []; + this.paragraphLineSlots = []; + this.paragraphSoftStarts = []; + this.coverRectPtr = 0; + this.locked = init.locked ?? false; + } + + // Captured pen positions are only valid for the text AND face they were + // measured from; a size or family change moves every glyph. + positionsKey(): string { + return `${this.text}\u0000${this.fontId}\u0000${this.fontSize}`; + } + + private positionsCurrent(): boolean { + return this.charPositionsKey === this.positionsKey(); + } + + // Display/serialization projection only. + snapshot(): TextRunSnapshot { + return { + id: this.id, + pageIndex: this.pageIndex, + bounds: { ...this.bounds }, + matrix: { ...this.matrix }, + text: this.text, + fontId: this.fontId, + fontSize: this.fontSize, + fill: { ...this.fill }, + fontSubset: this.fontSubset, + renderMode: this.renderMode || undefined, + stroke: this.stroke ? { ...this.stroke } : undefined, + strokeWidth: this.strokeWidth || undefined, + charStartsX: this.positionsCurrent() + ? (this.charStartsX ?? undefined) + : undefined, + charEndsX: this.positionsCurrent() + ? (this.charEndsX ?? undefined) + : undefined, + charSpacingPt: this.charSpacingPt || undefined, + paragraphLineHeight: this.paragraphLineHeight, + paragraphLineCount: this.paragraphMemberPtrs.length || undefined, + paragraphSlotCount: this.paragraphLineSlots.length || undefined, + paragraphBaselines: this.lineBaselines(), + paragraphLineLefts: this.lineLefts(), + locked: this.locked || undefined, + }; + } + + private lineBaselines(): number[] | undefined { + if (this.paragraphLineSlots.length > 0) { + return this.paragraphLineSlots.map((s) => s.baselineY); + } + return this.paragraphMemberFs.length > 0 + ? [...this.paragraphMemberFs] + : undefined; + } + + private lineLefts(): number[] | undefined { + return this.paragraphLineSlots.length > 0 + ? this.paragraphLineSlots.map((s) => s.matrixE) + : undefined; + } +} diff --git a/frontend/editor/src/core/tools/pdfTextEditor/model/affine.ts b/frontend/editor/src/core/tools/pdfTextEditor/model/affine.ts new file mode 100644 index 0000000000..982f3d7c13 --- /dev/null +++ b/frontend/editor/src/core/tools/pdfTextEditor/model/affine.ts @@ -0,0 +1,96 @@ +import type { Affine, PageRect } from "@app/tools/pdfTextEditor/types"; + +const IDENTITY: Affine = { a: 1, b: 0, c: 0, d: 1, e: 0, f: 0 }; + +/** Map a point through an affine: (x,y) -> (a·x + c·y + e, b·x + d·y + f). */ +export function applyAffine( + t: Affine, + x: number, + y: number, +): { x: number; y: number } { + return { x: t.a * x + t.c * y + t.e, y: t.b * x + t.d * y + t.f }; +} + +/** Compose two affines: `parent ∘ child` (child applied first, then parent). */ +export function composeAffine(parent: Affine, child: Affine): Affine { + return { + a: parent.a * child.a + parent.c * child.b, + b: parent.b * child.a + parent.d * child.b, + c: parent.a * child.c + parent.c * child.d, + d: parent.b * child.c + parent.d * child.d, + e: parent.a * child.e + parent.c * child.f + parent.e, + f: parent.b * child.e + parent.d * child.f + parent.f, + }; +} + +/** Transform a rect by an affine and return the new AABB (4 corners, min/max). */ +export function transformRectAABB(t: Affine, r: PageRect): PageRect { + const cs = [ + applyAffine(t, r.x, r.y), + applyAffine(t, r.x + r.width, r.y), + applyAffine(t, r.x, r.y + r.height), + applyAffine(t, r.x + r.width, r.y + r.height), + ]; + const xs = cs.map((c) => c.x); + const ys = cs.map((c) => c.y); + const minX = Math.min(...xs); + const minY = Math.min(...ys); + return { + x: minX, + y: minY, + width: Math.max(...xs) - minX, + height: Math.max(...ys) - minY, + }; +} + +/** Inverse of an affine, or identity when singular (degenerate linear part). */ +export function invertAffine(t: Affine): Affine { + const det = t.a * t.d - t.b * t.c; + if (!det || !Number.isFinite(det)) return { ...IDENTITY }; + const a = t.d / det; + const b = -t.b / det; + const c = -t.c / det; + const d = t.a / det; + return { a, b, c, d, e: -(a * t.e + c * t.f), f: -(b * t.e + d * t.f) }; +} + +/** Axis-aligned bounds of an image's projected 1x1 unit square under `m`. */ +export function imageMatrixBounds(m: Affine): PageRect { + const xs = [m.e, m.e + m.a, m.e + m.c, m.e + m.a + m.c]; + const ys = [m.f, m.f + m.b, m.f + m.d, m.f + m.b + m.d]; + const minX = Math.min(...xs); + const minY = Math.min(...ys); + return { + x: minX, + y: minY, + width: Math.max(...xs) - minX, + height: Math.max(...ys) - minY, + }; +} + +// New RAW image matrix when the user moves/resizes the image's display-space +// AABB from `prevBounds` to `nextBounds`. +export function remapImageMatrix( + prev: Affine, + prevBounds: PageRect, + nextBounds: PageRect, + display: Affine, +): Affine { + const A = display; + const Ainv = invertAffine(A); + const origDisp = transformRectAABB(A, prevBounds); + const targetDisp = transformRectAABB(A, nextBounds); + const sx = origDisp.width > 1e-6 ? targetDisp.width / origDisp.width : 1; + const sy = origDisp.height > 1e-6 ? targetDisp.height / origDisp.height : 1; + // Display-space scale+translate mapping origDisp -> targetDisp (axis-aligned). + const S: Affine = { + a: sx, + b: 0, + c: 0, + d: sy, + e: targetDisp.x - sx * origDisp.x, + f: targetDisp.y - sy * origDisp.y, + }; + // raw' = A⁻¹ ∘ S ∘ A ∘ prev + return composeAffine(Ainv, composeAffine(S, composeAffine(A, prev))); +} diff --git a/frontend/editor/src/core/tools/pdfTextEditor/pdfTextEditorTypes.ts b/frontend/editor/src/core/tools/pdfTextEditor/pdfTextEditorTypes.ts deleted file mode 100644 index 3bb5087a8a..0000000000 --- a/frontend/editor/src/core/tools/pdfTextEditor/pdfTextEditorTypes.ts +++ /dev/null @@ -1,233 +0,0 @@ -export interface PdfJsonFontCidSystemInfo { - registry?: string | null; - ordering?: string | null; - supplement?: number | null; -} - -export interface PdfJsonTextColor { - colorSpace?: string | null; - components?: number[] | null; -} - -export interface PdfJsonCosValue { - type?: string | null; - value?: unknown; - items?: PdfJsonCosValue[] | null; - entries?: Record | null; - stream?: PdfJsonStream | null; -} - -export interface PdfJsonFont { - id?: string; - pageNumber?: number | null; - uid?: string | null; - baseName?: string | null; - subtype?: string | null; - encoding?: string | null; - cidSystemInfo?: PdfJsonFontCidSystemInfo | null; - embedded?: boolean | null; - program?: string | null; - programFormat?: string | null; - webProgram?: string | null; - webProgramFormat?: string | null; - pdfProgram?: string | null; - pdfProgramFormat?: string | null; - toUnicode?: string | null; - standard14Name?: string | null; - fontDescriptorFlags?: number | null; - ascent?: number | null; - descent?: number | null; - capHeight?: number | null; - xHeight?: number | null; - italicAngle?: number | null; - unitsPerEm?: number | null; - cosDictionary?: PdfJsonCosValue | null; -} - -export interface PdfJsonTextElement { - text?: string | null; - fontId?: string | null; - fontSize?: number | null; - fontMatrixSize?: number | null; - fontSizeInPt?: number | null; - characterSpacing?: number | null; - wordSpacing?: number | null; - spaceWidth?: number | null; - zOrder?: number | null; - horizontalScaling?: number | null; - leading?: number | null; - rise?: number | null; - renderingMode?: number | null; - x?: number | null; - y?: number | null; - width?: number | null; - height?: number | null; - textMatrix?: number[] | null; - fillColor?: PdfJsonTextColor | null; - strokeColor?: PdfJsonTextColor | null; - charCodes?: number[] | null; - fallbackUsed?: boolean | null; -} - -export interface PdfJsonImageElement { - id?: string | null; - objectName?: string | null; - inlineImage?: boolean | null; - nativeWidth?: number | null; - nativeHeight?: number | null; - x?: number | null; - y?: number | null; - width?: number | null; - height?: number | null; - left?: number | null; - right?: number | null; - top?: number | null; - bottom?: number | null; - transform?: number[] | null; - zOrder?: number | null; - imageData?: string | null; - imageFormat?: string | null; -} - -export interface PdfJsonStream { - dictionary?: Record | null; - rawData?: string | null; -} - -export interface PdfJsonPage { - pageNumber?: number | null; - width?: number | null; - height?: number | null; - rotation?: number | null; - mediaBox?: number[] | null; - cropBox?: number[] | null; - textElements?: PdfJsonTextElement[] | null; - imageElements?: PdfJsonImageElement[] | null; - resources?: unknown; - contentStreams?: PdfJsonStream[] | null; -} - -export interface PdfJsonMetadata { - title?: string | null; - author?: string | null; - subject?: string | null; - keywords?: string | null; - creator?: string | null; - producer?: string | null; - creationDate?: string | null; - modificationDate?: string | null; - trapped?: string | null; - numberOfPages?: number | null; -} - -export interface PdfJsonDocument { - metadata?: PdfJsonMetadata | null; - xmpMetadata?: string | null; - fonts?: PdfJsonFont[] | null; - pages?: PdfJsonPage[] | null; - lazyImages?: boolean | null; -} - -export interface PdfJsonPageDimension { - pageNumber?: number | null; - width?: number | null; - height?: number | null; - rotation?: number | null; -} - -export interface PdfJsonDocumentMetadata { - metadata?: PdfJsonMetadata | null; - xmpMetadata?: string | null; - fonts?: PdfJsonFont[] | null; - pageDimensions?: PdfJsonPageDimension[] | null; - formFields?: unknown[] | null; - lazyImages?: boolean | null; -} - -export interface BoundingBox { - left: number; - right: number; - top: number; - bottom: number; -} - -export interface TextGroup { - id: string; - pageIndex: number; - fontId?: string | null; - fontSize?: number | null; - fontMatrixSize?: number | null; - lineSpacing?: number | null; - lineElementCounts?: number[] | null; - color?: string | null; - fontWeight?: number | "normal" | "bold" | null; - rotation?: number | null; - anchor?: { x: number; y: number } | null; - baselineLength?: number | null; - baseline?: number | null; - elements: PdfJsonTextElement[]; - originalElements: PdfJsonTextElement[]; - text: string; - originalText: string; - bounds: BoundingBox; - childLineGroups?: TextGroup[] | null; -} - -export const DEFAULT_PAGE_WIDTH = 612; -export const DEFAULT_PAGE_HEIGHT = 792; - -export interface ConversionProgress { - percent: number; - stage: string; - message: string; - current?: number; - total?: number; -} - -export interface PdfTextEditorViewData { - document: PdfJsonDocument | null; - groupsByPage: TextGroup[][]; - imagesByPage: PdfJsonImageElement[][]; - pagePreviews: Map; - selectedPage: number; - dirtyPages: boolean[]; - hasDocument: boolean; - hasVectorPreview: boolean; - fileName: string; - errorMessage: string | null; - isGeneratingPdf: boolean; - isConverting: boolean; - conversionProgress: ConversionProgress | null; - hasChanges: boolean; - forceSingleTextElement: boolean; - groupingMode: "auto" | "paragraph" | "singleLine"; - autoScaleText: boolean; - onAutoScaleTextChange: (value: boolean) => void; - requestPagePreview: (pageIndex: number, scale: number) => void; - onSelectPage: (pageIndex: number) => void; - onGroupEdit: (pageIndex: number, groupId: string, value: string) => void; - onGroupDelete: (pageIndex: number, groupId: string) => void; - onImageTransform: ( - pageIndex: number, - imageId: string, - next: { - left: number; - bottom: number; - width: number; - height: number; - transform: number[]; - }, - ) => void; - onImageReset: (pageIndex: number, imageId: string) => void; - onReset: () => void; - onDownloadJson: () => void; - onGeneratePdf: () => void; - onGeneratePdfForNavigation: () => Promise; - onSaveToWorkbench: () => Promise; - isSavingToWorkbench: boolean; - onForceSingleTextElementChange: (value: boolean) => void; - onGroupingModeChange: (value: "auto" | "paragraph" | "singleLine") => void; - onMergeGroups: (pageIndex: number, groupIds: string[]) => boolean; - onUngroupGroup: (pageIndex: number, groupId: string) => boolean; - onLoadFile: (file: File) => void; -} diff --git a/frontend/editor/src/core/tools/pdfTextEditor/pdfTextEditorUtils.ts b/frontend/editor/src/core/tools/pdfTextEditor/pdfTextEditorUtils.ts deleted file mode 100644 index 522e25684f..0000000000 --- a/frontend/editor/src/core/tools/pdfTextEditor/pdfTextEditorUtils.ts +++ /dev/null @@ -1,1525 +0,0 @@ -import { - BoundingBox, - PdfJsonDocument, - PdfJsonPage, - PdfJsonTextElement, - PdfJsonImageElement, - TextGroup, - DEFAULT_PAGE_HEIGHT, - DEFAULT_PAGE_WIDTH, -} from "@app/tools/pdfTextEditor/pdfTextEditorTypes"; - -const LINE_TOLERANCE = 2; -const GAP_FACTOR = 0.6; -const SPACE_MIN_GAP = 1.5; -const MIN_CHAR_WIDTH_FACTOR = 0.35; -const MAX_CHAR_WIDTH_FACTOR = 1.25; -const EXTRA_GAP_RATIO = 0.8; - -type FontMetrics = { - unitsPerEm: number; - ascent: number; - descent: number; -}; - -type FontMetricsMap = Map; - -const sanitizeParagraphText = (text: string | undefined | null): string => { - if (!text) { - return ""; - } - return text.replace(/\r?\n/g, ""); -}; - -const splitParagraphIntoLines = (text: string | undefined | null): string[] => { - if (text === null || text === undefined) { - return [""]; - } - return text.replace(/\r/g, "").split("\n"); -}; - -const extractElementBaseline = (element: PdfJsonTextElement): number | null => { - if (!element) { - return null; - } - if (element.textMatrix && element.textMatrix.length >= 6) { - const baseline = element.textMatrix[5]; - return typeof baseline === "number" ? baseline : null; - } - if (typeof element.y === "number") { - return element.y; - } - return null; -}; - -const shiftElementsBy = ( - elements: PdfJsonTextElement[], - delta: number, -): PdfJsonTextElement[] => { - if (delta === 0) { - return elements.map(cloneTextElement); - } - return elements.map((element) => { - const clone = cloneTextElement(element); - if (clone.textMatrix && clone.textMatrix.length >= 6) { - const matrix = [...clone.textMatrix]; - matrix[5] = (matrix[5] ?? 0) + delta; - clone.textMatrix = matrix; - } - if (typeof clone.y === "number") { - clone.y += delta; - } else if (clone.y === null || clone.y === undefined) { - clone.y = delta; - } - return clone; - }); -}; - -const countGraphemes = (text: string): number => { - if (!text) { - return 0; - } - return Array.from(text).length; -}; - -const metricsFor = ( - metrics: FontMetricsMap | undefined, - fontId?: string | null, -): FontMetrics | undefined => { - if (!metrics || !fontId) { - return undefined; - } - return metrics.get(fontId) ?? undefined; -}; - -const buildFontMetrics = ( - document: PdfJsonDocument | null | undefined, -): FontMetricsMap => { - const metrics: FontMetricsMap = new Map(); - document?.fonts?.forEach((font) => { - if (!font) { - return; - } - const unitsPerEm = - font.unitsPerEm && font.unitsPerEm > 0 ? font.unitsPerEm : 1000; - const ascent = font.ascent ?? unitsPerEm * 0.8; - const descent = font.descent ?? -(unitsPerEm * 0.2); - const metric: FontMetrics = { unitsPerEm, ascent, descent }; - if (font.id) { - metrics.set(font.id, metric); - } - if (font.uid) { - metrics.set(font.uid, metric); - } - }); - return metrics; -}; - -export const valueOr = ( - value: number | null | undefined, - fallback = 0, -): number => { - if (value === null || value === undefined || Number.isNaN(value)) { - return fallback; - } - return value; -}; - -export const cloneTextElement = ( - element: PdfJsonTextElement, -): PdfJsonTextElement => ({ - ...element, - textMatrix: element.textMatrix - ? [...element.textMatrix] - : (element.textMatrix ?? undefined), -}); - -const clearGlyphHints = (element: PdfJsonTextElement): void => { - if (!element) { - return; - } - element.charCodes = undefined; -}; - -export const cloneImageElement = ( - element: PdfJsonImageElement, -): PdfJsonImageElement => ({ - ...element, - transform: element.transform - ? [...element.transform] - : (element.transform ?? undefined), -}); - -const getBaseline = (element: PdfJsonTextElement): number => { - if (element.textMatrix && element.textMatrix.length === 6) { - return valueOr(element.textMatrix[5]); - } - return valueOr(element.y); -}; - -const getX = (element: PdfJsonTextElement): number => { - if (element.textMatrix && element.textMatrix.length === 6) { - return valueOr(element.textMatrix[4]); - } - return valueOr(element.x); -}; - -const getWidth = ( - element: PdfJsonTextElement, - metrics?: FontMetricsMap, -): number => { - const width = valueOr(element.width, 0); - if (width > 0) { - return width; - } - - const text = element.text ?? ""; - const glyphCount = Math.max(1, countGraphemes(text)); - const spacingFallback = Math.max( - valueOr(element.spaceWidth, 0), - valueOr(element.wordSpacing, 0), - valueOr(element.characterSpacing, 0), - ); - - if (spacingFallback > 0 && text.trim().length === 0) { - return spacingFallback; - } - - const fontSize = getFontSize(element); - const fontMetrics = metricsFor(metrics, element.fontId); - if (fontMetrics) { - const unitsPerEm = - fontMetrics.unitsPerEm > 0 ? fontMetrics.unitsPerEm : 1000; - const ascentUnits = fontMetrics.ascent ?? unitsPerEm * 0.8; - const descentUnits = Math.abs(fontMetrics.descent ?? -(unitsPerEm * 0.2)); - const combinedUnits = Math.max( - unitsPerEm * 0.8, - ascentUnits + descentUnits, - ); - const averageAdvanceUnits = Math.max( - unitsPerEm * 0.5, - combinedUnits / Math.max(1, glyphCount), - ); - const fallbackWidth = - (averageAdvanceUnits / unitsPerEm) * glyphCount * fontSize; - if (fallbackWidth > 0) { - return fallbackWidth; - } - } - - return fontSize * glyphCount * 0.5; -}; - -const getFontSize = (element: PdfJsonTextElement): number => - valueOr(element.fontMatrixSize ?? element.fontSize, 12); - -const getHeight = ( - element: PdfJsonTextElement, - metrics?: FontMetricsMap, -): number => { - const height = valueOr(element.height, 0); - if (height > 0) { - return height; - } - const fontSize = getFontSize(element); - const fontMetrics = metricsFor(metrics, element.fontId); - if (fontMetrics) { - const unitsPerEm = - fontMetrics.unitsPerEm > 0 ? fontMetrics.unitsPerEm : 1000; - const ascentUnits = fontMetrics.ascent ?? unitsPerEm * 0.8; - const descentUnits = Math.abs(fontMetrics.descent ?? -(unitsPerEm * 0.2)); - const totalUnits = Math.max(unitsPerEm, ascentUnits + descentUnits); - if (totalUnits > 0) { - return (totalUnits / unitsPerEm) * fontSize; - } - } - return fontSize; -}; - -const getElementBounds = ( - element: PdfJsonTextElement, - metrics?: FontMetricsMap, -): BoundingBox => { - const left = getX(element); - const width = getWidth(element, metrics); - const baseline = getBaseline(element); - const height = getHeight(element, metrics); - - let ascentRatio = 0.8; - let descentRatio = 0.2; - const fontMetrics = metricsFor(metrics, element.fontId); - if (fontMetrics) { - const unitsPerEm = - fontMetrics.unitsPerEm > 0 ? fontMetrics.unitsPerEm : 1000; - const ascentUnits = fontMetrics.ascent ?? unitsPerEm * 0.8; - const descentUnits = Math.abs(fontMetrics.descent ?? -(unitsPerEm * 0.2)); - const totalUnits = Math.max(unitsPerEm, ascentUnits + descentUnits); - if (totalUnits > 0) { - ascentRatio = ascentUnits / totalUnits; - descentRatio = descentUnits / totalUnits; - } - } - - const bottom = baseline + height * ascentRatio; - const top = baseline - height * descentRatio; - return { - left, - right: left + width, - top, - bottom, - }; -}; - -export const getImageBounds = (element: PdfJsonImageElement): BoundingBox => { - const left = valueOr(element.left ?? element.x, 0); - const computedWidth = valueOr( - element.width, - Math.max(valueOr(element.right, left) - left, 0), - ); - const right = valueOr( - element.right ?? left + computedWidth, - left + computedWidth, - ); - const bottom = valueOr(element.bottom ?? element.y, 0); - const computedHeight = valueOr( - element.height, - Math.max(valueOr(element.top, bottom) - bottom, 0), - ); - const top = valueOr( - element.top ?? bottom + computedHeight, - bottom + computedHeight, - ); - return { - left, - right, - bottom, - top, - }; -}; - -const getSpacingHint = (element: PdfJsonTextElement): number => { - const spaceWidth = valueOr(element.spaceWidth, 0); - if (spaceWidth > 0) { - return spaceWidth; - } - const wordSpacing = valueOr(element.wordSpacing, 0); - if (wordSpacing > 0) { - return wordSpacing; - } - const characterSpacing = valueOr(element.characterSpacing, 0); - return Math.max(characterSpacing, 0); -}; - -const estimateCharWidth = ( - element: PdfJsonTextElement, - avgFontSize: number, - metrics?: FontMetricsMap, -): number => { - const rawWidth = getWidth(element, metrics); - const minWidth = avgFontSize * MIN_CHAR_WIDTH_FACTOR; - const maxWidth = avgFontSize * MAX_CHAR_WIDTH_FACTOR; - return Math.min(Math.max(rawWidth, minWidth), maxWidth); -}; - -const mergeBounds = (bounds: BoundingBox[]): BoundingBox => { - if (bounds.length === 0) { - return { left: 0, right: 0, top: 0, bottom: 0 }; - } - return bounds.reduce( - (acc, current) => ({ - left: Math.min(acc.left, current.left), - right: Math.max(acc.right, current.right), - top: Math.min(acc.top, current.top), - bottom: Math.max(acc.bottom, current.bottom), - }), - { ...bounds[0] }, - ); -}; - -const shouldInsertSpace = ( - prev: PdfJsonTextElement, - current: PdfJsonTextElement, - metrics?: FontMetricsMap, -): boolean => { - const prevRight = getX(prev) + getWidth(prev, metrics); - const trailingGap = Math.max(0, getX(current) - prevRight); - const avgFontSize = (getFontSize(prev) + getFontSize(current)) / 2; - const baselineAdvance = Math.max(0, getX(current) - getX(prev)); - const charWidthEstimate = estimateCharWidth(prev, avgFontSize, metrics); - const inferredGap = Math.max(0, baselineAdvance - charWidthEstimate); - const spacingHint = Math.max( - SPACE_MIN_GAP, - getSpacingHint(prev), - getSpacingHint(current), - avgFontSize * GAP_FACTOR, - ); - - if (trailingGap > spacingHint) { - return true; - } - - if (inferredGap > spacingHint * EXTRA_GAP_RATIO) { - return true; - } - - const prevText = (prev.text ?? "").trimEnd(); - if (prevText.endsWith("-")) { - return false; - } - - return false; -}; - -const buildGroupText = ( - elements: PdfJsonTextElement[], - metrics?: FontMetricsMap, -): string => { - let result = ""; - elements.forEach((element, index) => { - const value = element.text ?? ""; - if (index === 0) { - result += value; - return; - } - - const previous = elements[index - 1]; - const needsSpace = shouldInsertSpace(previous, element, metrics); - const startsWithWhitespace = /^\s/u.test(value); - - if (needsSpace && !startsWithWhitespace) { - result += " "; - } - result += value; - }); - return result; -}; - -const rgbToCss = (components: number[]): string => { - if (components.length >= 3) { - const r = Math.round(Math.max(0, Math.min(1, components[0])) * 255); - const g = Math.round(Math.max(0, Math.min(1, components[1])) * 255); - const b = Math.round(Math.max(0, Math.min(1, components[2])) * 255); - return `rgb(${r}, ${g}, ${b})`; - } - return "rgb(0, 0, 0)"; -}; - -const cmykToCss = (components: number[]): string => { - if (components.length >= 4) { - const c = Math.max(0, Math.min(1, components[0])); - const m = Math.max(0, Math.min(1, components[1])); - const y = Math.max(0, Math.min(1, components[2])); - const k = Math.max(0, Math.min(1, components[3])); - const r = Math.round(255 * (1 - c) * (1 - k)); - const g = Math.round(255 * (1 - m) * (1 - k)); - const b = Math.round(255 * (1 - y) * (1 - k)); - return `rgb(${r}, ${g}, ${b})`; - } - return "rgb(0, 0, 0)"; -}; - -const grayToCss = (components: number[]): string => { - if (components.length >= 1) { - const gray = Math.round(Math.max(0, Math.min(1, components[0])) * 255); - return `rgb(${gray}, ${gray}, ${gray})`; - } - return "rgb(0, 0, 0)"; -}; - -const extractColor = (element: PdfJsonTextElement): string | null => { - const fillColor = element.fillColor; - if ( - !fillColor || - !fillColor.components || - fillColor.components.length === 0 - ) { - return null; - } - - const colorSpace = (fillColor.colorSpace ?? "").toLowerCase(); - - if (colorSpace.includes("rgb") || colorSpace.includes("srgb")) { - return rgbToCss(fillColor.components); - } - if (colorSpace.includes("cmyk")) { - return cmykToCss(fillColor.components); - } - if (colorSpace.includes("gray") || colorSpace.includes("grey")) { - return grayToCss(fillColor.components); - } - - // Default to RGB interpretation - if (fillColor.components.length >= 3) { - return rgbToCss(fillColor.components); - } - if (fillColor.components.length === 1) { - return grayToCss(fillColor.components); - } - - return null; -}; - -const RAD_TO_DEG = 180 / Math.PI; - -const normalizeAngle = (angle: number): number => { - let normalized = angle % 360; - if (normalized > 180) { - normalized -= 360; - } else if (normalized <= -180) { - normalized += 360; - } - return normalized; -}; - -const extractElementRotation = (element: PdfJsonTextElement): number | null => { - const matrix = element.textMatrix; - if (!matrix || matrix.length !== 6) { - return null; - } - const a = matrix[0]; - const b = matrix[1]; - if (Math.abs(a) < 1e-6 && Math.abs(b) < 1e-6) { - return null; - } - const angle = Math.atan2(b, a) * RAD_TO_DEG; - if (Math.abs(angle) < 0.5) { - return null; - } - return normalizeAngle(angle); -}; - -const computeGroupRotation = ( - elements: PdfJsonTextElement[], -): number | null => { - const angles = elements - .map(extractElementRotation) - .filter((angle): angle is number => angle !== null); - if (angles.length === 0) { - return null; - } - const vector = angles.reduce( - (acc, angle) => { - const radians = (angle * Math.PI) / 180; - acc.x += Math.cos(radians); - acc.y += Math.sin(radians); - return acc; - }, - { x: 0, y: 0 }, - ); - if (Math.abs(vector.x) < 1e-6 && Math.abs(vector.y) < 1e-6) { - return null; - } - const average = Math.atan2(vector.y, vector.x) * RAD_TO_DEG; - const normalized = normalizeAngle(average); - return Math.abs(normalized) < 0.5 ? null : normalized; -}; - -const getAnchorPoint = ( - element: PdfJsonTextElement, -): { x: number; y: number } => { - if (element.textMatrix && element.textMatrix.length === 6) { - return { - x: valueOr(element.textMatrix[4]), - y: valueOr(element.textMatrix[5]), - }; - } - return { - x: valueOr(element.x), - y: valueOr(element.y), - }; -}; - -const computeBaselineLength = ( - elements: PdfJsonTextElement[], - metrics?: FontMetricsMap, -): number => - elements.reduce((acc, current) => acc + getWidth(current, metrics), 0); - -const computeAverageBaseline = ( - elements: PdfJsonTextElement[], -): number | null => { - if (elements.length === 0) { - return null; - } - let sum = 0; - elements.forEach((element) => { - sum += getBaseline(element); - }); - return sum / elements.length; -}; - -const createGroup = ( - pageIndex: number, - idSuffix: number, - elements: PdfJsonTextElement[], - metrics?: FontMetricsMap, -): TextGroup => { - const clones = elements.map(cloneTextElement); - const originalClones = clones.map(cloneTextElement); - const bounds = mergeBounds( - elements.map((element) => getElementBounds(element, metrics)), - ); - const firstElement = elements[0]; - const rotation = computeGroupRotation(elements); - const anchor = rotation !== null ? getAnchorPoint(firstElement) : null; - const baselineLength = computeBaselineLength(elements, metrics); - const baseline = computeAverageBaseline(elements); - - return { - id: `${pageIndex}-${idSuffix}`, - pageIndex, - fontId: firstElement?.fontId, - fontSize: firstElement?.fontSize, - fontMatrixSize: firstElement?.fontMatrixSize, - color: firstElement ? extractColor(firstElement) : null, - fontWeight: null, // Will be determined from font descriptor - rotation, - anchor, - baselineLength, - baseline, - elements: clones, - originalElements: originalClones, - text: buildGroupText(elements, metrics), - originalText: buildGroupText(elements, metrics), - bounds, - }; -}; - -const cloneLineTemplate = (line: TextGroup): TextGroup => ({ - ...line, - childLineGroups: null, - lineElementCounts: null, - lineSpacing: null, - elements: line.elements.map(cloneTextElement), - originalElements: line.originalElements.map(cloneTextElement), -}); - -const groupLinesIntoParagraphs = ( - lineGroups: TextGroup[], - pageWidth: number, - metrics?: FontMetricsMap, -): TextGroup[] => { - if (lineGroups.length === 0) { - return []; - } - - const paragraphs: TextGroup[][] = []; - let currentParagraph: TextGroup[] = [lineGroups[0]]; - const bulletFlags = new Map(); - bulletFlags.set(lineGroups[0].id, false); - - for (let i = 1; i < lineGroups.length; i++) { - const prevLine = lineGroups[i - 1]; - const currentLine = lineGroups[i]; - - // Calculate line spacing - const prevBaseline = prevLine.baseline ?? 0; - const currentBaseline = currentLine.baseline ?? 0; - const lineSpacing = Math.abs(prevBaseline - currentBaseline); - - // Calculate average font size - const prevFontSize = prevLine.fontSize ?? 12; - const currentFontSize = currentLine.fontSize ?? 12; - const avgFontSize = (prevFontSize + currentFontSize) / 2; - - // Check horizontal alignment (left edge) - const prevLeft = prevLine.bounds.left; - const currentLeft = currentLine.bounds.left; - const leftAlignmentTolerance = avgFontSize * 0.3; - const isLeftAligned = - Math.abs(prevLeft - currentLeft) <= leftAlignmentTolerance; - - // Check if fonts match - const sameFont = prevLine.fontId === currentLine.fontId; - - // Check for consistent spacing rather than expected spacing - // Line spacing in PDFs can range from 1.0x to 3.0x font size - // We just want to ensure spacing is consistent between consecutive lines - // and not excessively large (which would indicate a paragraph break) - const maxReasonableSpacing = avgFontSize * 3.0; // Max ~3x font size for normal line spacing - const hasReasonableSpacing = lineSpacing <= maxReasonableSpacing; - - // Check if current line looks like a bullet/list item - const prevRight = prevLine.bounds.right; - const currentRight = currentLine.bounds.right; - const prevWidth = prevRight - prevLeft; - const currentWidth = currentRight - currentLeft; - - // Count word count to help identify bullets (typically short) - const prevWords = (prevLine.text ?? "") - .split(/\s+/) - .filter((w) => w.length > 0).length; - const currentWords = (currentLine.text ?? "") - .split(/\s+/) - .filter((w) => w.length > 0).length; - const prevText = (prevLine.text ?? "").trim(); - const currentText = (currentLine.text ?? "").trim(); - - // Bullet detection - look for bullet markers or very short lines - const bulletMarkerRegex = - /^[\u2022\u2023\u25E6\u2043\u2219•·◦‣⁃\-*]\s|^\d+[.)]\s|^[a-z][.)]\s/i; - const prevHasBulletMarker = bulletMarkerRegex.test(prevText); - const currentHasBulletMarker = bulletMarkerRegex.test(currentText); - - // True bullets are: - // 1. Have bullet markers/numbers OR - // 2. Very short (< 10 words) AND much narrower than average (< 60% of page width) - const headingKeywords = [ - "action items", - "next steps", - "notes", - "logistics", - "tasks", - ]; - const normalizedPageWidth = pageWidth > 0 ? pageWidth : avgFontSize * 70; - const maxReferenceWidth = - normalizedPageWidth > 0 ? normalizedPageWidth : avgFontSize * 70; - const indentDelta = currentLeft - prevLeft; - const indentThreshold = Math.max(avgFontSize * 0.6, 8); - const hasIndent = indentDelta > indentThreshold; - const currentWidthRatio = - maxReferenceWidth > 0 ? currentWidth / maxReferenceWidth : 0; - const prevWidthRatio = - maxReferenceWidth > 0 ? prevWidth / maxReferenceWidth : 0; - const prevLooksLikeHeading = - prevText.endsWith(":") || - (prevWords <= 4 && prevWidthRatio < 0.4) || - headingKeywords.some((keyword) => - prevText.toLowerCase().includes(keyword), - ); - - const wrapCandidate = - !currentHasBulletMarker && - !hasIndent && - !prevLooksLikeHeading && - currentWords <= 12 && - currentWidthRatio < 0.45 && - Math.abs(prevLeft - currentLeft) <= leftAlignmentTolerance && - currentWidth < prevWidth * 0.85; - - const currentIsBullet = wrapCandidate - ? false - : currentHasBulletMarker || - (hasIndent && (currentWords <= 14 || currentWidthRatio <= 0.65)) || - (prevLooksLikeHeading && - (currentWords <= 16 || - currentWidthRatio <= 0.8 || - prevWidthRatio < 0.35)) || - (currentWords <= 8 && - currentWidthRatio <= 0.45 && - prevWidth - currentWidth > avgFontSize * 4); - - const prevIsBullet = bulletFlags.get(prevLine.id) ?? prevHasBulletMarker; - bulletFlags.set(currentLine.id, currentIsBullet); - - // Detect paragraph→bullet transition - const likelyBulletStart = !prevIsBullet && currentIsBullet; - - // Don't merge two consecutive bullets - const bothAreBullets = prevIsBullet && currentIsBullet; - - // Merge into paragraph if: - // 1. Left aligned - // 2. Same font - // 3. Reasonable line spacing - // 4. NOT transitioning to bullets - // 5. NOT both are bullets - const shouldMerge = - isLeftAligned && - sameFont && - hasReasonableSpacing && - !likelyBulletStart && - !bothAreBullets && - !currentIsBullet; - - if (i < 10 || likelyBulletStart || bothAreBullets || !shouldMerge) { - console.log(` Line ${i}:`); - console.log( - ` prev: "${prevText.substring(0, 40)}" (${prevWords}w, ${prevWidth.toFixed(0)}pt, marker:${prevHasBulletMarker}, bullet:${prevIsBullet})`, - ); - console.log( - ` curr: "${currentText.substring(0, 40)}" (${currentWords}w, ${currentWidth.toFixed(0)}pt, marker:${currentHasBulletMarker}, bullet:${currentIsBullet})`, - ); - console.log( - ` checks: leftAlign:${isLeftAligned} (${Math.abs(prevLeft - currentLeft).toFixed(1)}pt), sameFont:${sameFont}, spacing:${hasReasonableSpacing} (${lineSpacing.toFixed(1)}pt/${maxReasonableSpacing.toFixed(1)}pt)`, - ); - console.log( - ` decision: merge=${shouldMerge} (bulletStart:${likelyBulletStart}, bothBullets:${bothAreBullets})`, - ); - } - - if (shouldMerge) { - currentParagraph.push(currentLine); - } else { - paragraphs.push(currentParagraph); - currentParagraph = [currentLine]; - } - } - - // Don't forget the last paragraph - if (currentParagraph.length > 0) { - paragraphs.push(currentParagraph); - } - - // Merge line groups into single paragraph groups - return paragraphs.map((lines, _paragraphIndex) => { - if (lines.length === 1) { - return lines[0]; - } - - // Combine all elements from all lines - const lineTemplates = lines.map((line) => cloneLineTemplate(line)); - const flattenedLineTemplates = lineTemplates.flatMap((line) => - line.childLineGroups && line.childLineGroups.length > 0 - ? line.childLineGroups - : [line], - ); - const allLines = - flattenedLineTemplates.length > 0 - ? flattenedLineTemplates - : lineTemplates; - const allElements = allLines.flatMap((line) => line.originalElements); - const pageIndex = lines[0].pageIndex; - const lineElementCounts = allLines.map( - (line) => line.originalElements.length, - ); - - // Create merged group with newlines between lines - const paragraphText = allLines.map((line) => line.text).join("\n"); - const mergedBounds = mergeBounds(allLines.map((line) => line.bounds)); - const spacingValues: number[] = []; - for (let i = 1; i < allLines.length; i++) { - const prevBaseline = - allLines[i - 1].baseline ?? allLines[i - 1].bounds.bottom; - const currentBaseline = allLines[i].baseline ?? allLines[i].bounds.bottom; - const spacing = Math.abs(prevBaseline - currentBaseline); - if (spacing > 0) { - spacingValues.push(spacing); - } - } - const averageSpacing = - spacingValues.length > 0 - ? spacingValues.reduce((sum, value) => sum + value, 0) / - spacingValues.length - : null; - - const firstElement = allElements[0]; - const rotation = computeGroupRotation(allElements); - const anchor = rotation !== null ? getAnchorPoint(firstElement) : null; - const baselineLength = computeBaselineLength(allElements, metrics); - const baseline = computeAverageBaseline(allElements); - - return { - id: lines[0].id, // Keep the first line's ID - pageIndex, - fontId: firstElement?.fontId, - fontSize: firstElement?.fontSize, - fontMatrixSize: firstElement?.fontMatrixSize, - lineSpacing: averageSpacing, - lineElementCounts: lines.length > 1 ? lineElementCounts : null, - color: firstElement ? extractColor(firstElement) : null, - fontWeight: null, - rotation, - anchor, - baselineLength, - baseline, - elements: allElements.map(cloneTextElement), - originalElements: allElements.map(cloneTextElement), - text: paragraphText, - originalText: paragraphText, - bounds: mergedBounds, - childLineGroups: allLines, - }; - }); -}; - -export const groupPageTextElements = ( - page: PdfJsonPage | null | undefined, - pageIndex: number, - metrics?: FontMetricsMap, - groupingMode: "auto" | "paragraph" | "singleLine" = "auto", -): TextGroup[] => { - if (!page?.textElements || page.textElements.length === 0) { - return []; - } - - const pageWidth = valueOr(page.width, DEFAULT_PAGE_WIDTH); - - const elements = page.textElements - .map(cloneTextElement) - .filter((element) => element.text !== null && element.text !== undefined); - - elements.sort((a, b) => getBaseline(b) - getBaseline(a)); - - const lines: { baseline: number; elements: PdfJsonTextElement[] }[] = []; - - elements.forEach((element) => { - const baseline = getBaseline(element); - const fontSize = getFontSize(element); - const tolerance = Math.max(LINE_TOLERANCE, fontSize * 0.12); - - const existingLine = lines.find( - (line) => Math.abs(line.baseline - baseline) <= tolerance, - ); - - if (existingLine) { - existingLine.elements.push(element); - } else { - lines.push({ baseline, elements: [element] }); - } - }); - - lines.forEach((line) => { - line.elements.sort((a, b) => getX(a) - getX(b)); - }); - - let groupCounter = 0; - const lineGroups: TextGroup[] = []; - - lines.forEach((line) => { - let currentBucket: PdfJsonTextElement[] = []; - - line.elements.forEach((element) => { - if (currentBucket.length === 0) { - currentBucket.push(element); - return; - } - - const previous = currentBucket[currentBucket.length - 1]; - const gap = - getX(element) - (getX(previous) + getWidth(previous, metrics)); - const avgFontSize = (getFontSize(previous) + getFontSize(element)) / 2; - const splitThreshold = Math.max(SPACE_MIN_GAP, avgFontSize * GAP_FACTOR); - - const sameFont = previous.fontId === element.fontId; - let shouldSplit = gap > splitThreshold * (sameFont ? 1.4 : 1.0); - - if (shouldSplit) { - const prevBaseline = getBaseline(previous); - const currentBaseline = getBaseline(element); - const baselineDelta = Math.abs(prevBaseline - currentBaseline); - const prevEndX = getX(previous) + getWidth(previous, metrics); - const _prevEndY = prevBaseline; - const diagonalGap = Math.hypot( - Math.max(0, getX(element) - prevEndX), - baselineDelta, - ); - const diagonalThreshold = Math.max(avgFontSize * 0.8, splitThreshold); - if (diagonalGap <= diagonalThreshold) { - shouldSplit = false; - } - } - - const previousRotation = extractElementRotation(previous); - const currentRotation = extractElementRotation(element); - if ( - shouldSplit && - previousRotation !== null && - currentRotation !== null && - Math.abs(normalizeAngle(previousRotation - currentRotation)) < 1 - ) { - shouldSplit = false; - } - - if (shouldSplit) { - lineGroups.push( - createGroup(pageIndex, groupCounter, currentBucket, metrics), - ); - groupCounter += 1; - currentBucket = [element]; - } else { - currentBucket.push(element); - } - }); - - if (currentBucket.length > 0) { - lineGroups.push( - createGroup(pageIndex, groupCounter, currentBucket, metrics), - ); - groupCounter += 1; - } - }); - - // Apply paragraph grouping based on mode - if (groupingMode === "singleLine") { - // Single line mode: skip paragraph grouping - return lineGroups; - } - - if (groupingMode === "paragraph") { - // Paragraph mode: always apply grouping - return groupLinesIntoParagraphs(lineGroups, pageWidth, metrics); - } - - // Auto mode: use heuristic to determine if we should group - // Analyze the page content to decide - let multiLineGroups = 0; - let totalWords = 0; - let longTextGroups = 0; - let totalGroups = 0; - const wordCounts: number[] = []; - let fullWidthLines = 0; - - // Define "full width" as extending to at least 70% of page width - const fullWidthThreshold = pageWidth * 0.7; - - lineGroups.forEach((group) => { - const text = (group.text || "").trim(); - if (text.length === 0) return; - - totalGroups++; - const lines = text.split("\n"); - const lineCount = lines.length; - const wordCount = text.split(/\s+/).filter((w) => w.length > 0).length; - - totalWords += wordCount; - wordCounts.push(wordCount); - - if (lineCount > 1) { - multiLineGroups++; - } - - if (wordCount >= 10 || text.length >= 50) { - longTextGroups++; - } - - // Check if this line extends close to the right margin (paragraph-like) - const rightEdge = group.bounds.right; - if (rightEdge >= fullWidthThreshold) { - fullWidthLines++; - } - }); - - if (totalGroups === 0) { - return lineGroups; - } - - const avgWordsPerGroup = totalWords / totalGroups; - const longTextRatio = longTextGroups / totalGroups; - const fullWidthRatio = fullWidthLines / totalGroups; - - // Calculate variance in line lengths (paragraphs have varying lengths, lists are uniform) - const variance = - wordCounts.reduce((sum, count) => { - const diff = count - avgWordsPerGroup; - return sum + diff * diff; - }, 0) / totalGroups; - const stdDev = Math.sqrt(variance); - const coefficientOfVariation = - avgWordsPerGroup > 0 ? stdDev / avgWordsPerGroup : 0; - - // Check each criterion - const criterion1 = avgWordsPerGroup > 5; - const criterion2 = longTextRatio > 0.4; - const criterion3 = coefficientOfVariation > 0.5 || fullWidthRatio > 0.6; // High variance OR many full-width lines = paragraph text - - const isParagraphPage = criterion1 && criterion2 && criterion3; - - // Log detection stats - console.log( - `📄 Page ${pageIndex} Grouping Analysis (mode: ${groupingMode}):`, - ); - console.log(` Stats:`); - console.log( - ` • Page width: ${pageWidth.toFixed(1)}pt (full-width threshold: ${fullWidthThreshold.toFixed(1)}pt)`, - ); - console.log(` • Multi-line groups: ${multiLineGroups}`); - console.log(` • Total groups: ${totalGroups}`); - console.log(` • Total words: ${totalWords}`); - console.log( - ` • Long text groups (≥10 words or ≥50 chars): ${longTextGroups}`, - ); - console.log(` • Full-width lines (≥70% page width): ${fullWidthLines}`); - console.log(` • Avg words per group: ${avgWordsPerGroup.toFixed(2)}`); - console.log(` • Long text ratio: ${(longTextRatio * 100).toFixed(1)}%`); - console.log(` • Full-width ratio: ${(fullWidthRatio * 100).toFixed(1)}%`); - console.log(` • Std deviation: ${stdDev.toFixed(2)}`); - console.log( - ` • Coefficient of variation: ${coefficientOfVariation.toFixed(2)}`, - ); - console.log(` Criteria:`); - console.log( - ` 1. Avg Words Per Group: ${criterion1 ? "✅ PASS" : "❌ FAIL"}`, - ); - console.log(` (${avgWordsPerGroup.toFixed(2)} > 5)`); - console.log(` 2. Long Text Ratio: ${criterion2 ? "✅ PASS" : "❌ FAIL"}`); - console.log(` (${(longTextRatio * 100).toFixed(1)}% > 40%)`); - console.log( - ` 3. Line Width Pattern: ${criterion3 ? "✅ PASS" : "❌ FAIL"}`, - ); - console.log( - ` (CV ${coefficientOfVariation.toFixed(2)} > 0.5 OR ${(fullWidthRatio * 100).toFixed(1)}% > 60%)`, - ); - console.log( - ` ${coefficientOfVariation > 0.5 ? "✓ High variance (varying line lengths)" : "✗ Low variance"} ${fullWidthRatio > 0.6 ? "✓ Many full-width lines (paragraph-like)" : "✗ Few full-width lines (list-like)"}`, - ); - console.log( - ` Decision: ${isParagraphPage ? "📝 PARAGRAPH MODE" : "📋 LINE MODE"}`, - ); - if (isParagraphPage) { - console.log(` Reason: All three criteria passed (AND logic)`); - } else { - const failedReasons = []; - if (!criterion1) failedReasons.push("low average words per group"); - if (!criterion2) failedReasons.push("low ratio of long text groups"); - if (!criterion3) - failedReasons.push( - "low variance and few full-width lines (list-like structure)", - ); - console.log(` Reason: ${failedReasons.join(", ")}`); - } - console.log(""); - - // Only apply paragraph grouping if it looks like a paragraph-heavy page - if (isParagraphPage) { - console.log(`🔀 Applying paragraph grouping to page ${pageIndex}`); - return groupLinesIntoParagraphs(lineGroups, pageWidth, metrics); - } - - // For sparse pages, keep lines separate - console.log(`📋 Keeping lines separate for page ${pageIndex}`); - return lineGroups; -}; - -export const groupDocumentText = ( - document: PdfJsonDocument | null | undefined, - groupingMode: "auto" | "paragraph" | "singleLine" = "auto", -): TextGroup[][] => { - const pages = document?.pages ?? []; - const metrics = buildFontMetrics(document); - return pages.map((page, index) => - groupPageTextElements(page, index, metrics, groupingMode), - ); -}; - -export const extractPageImages = ( - page: PdfJsonPage | null | undefined, - pageIndex: number, -): PdfJsonImageElement[] => { - const images = page?.imageElements ?? []; - return images.map((image, imageIndex) => { - const clone = cloneImageElement(image); - if (!clone.id || clone.id.trim().length === 0) { - clone.id = `page-${pageIndex}-image-${imageIndex}`; - } - return clone; - }); -}; - -export const extractDocumentImages = ( - document: PdfJsonDocument | null | undefined, -): PdfJsonImageElement[][] => { - const pages = document?.pages ?? []; - return pages.map((page, index) => extractPageImages(page, index)); -}; - -export const deepCloneDocument = ( - document: PdfJsonDocument, -): PdfJsonDocument => { - if (typeof structuredClone === "function") { - return structuredClone(document); - } - return JSON.parse(JSON.stringify(document)); -}; - -export const pageDimensions = ( - page: PdfJsonPage | null | undefined, -): { width: number; height: number } => { - const width = valueOr(page?.width, DEFAULT_PAGE_WIDTH); - const height = valueOr(page?.height, DEFAULT_PAGE_HEIGHT); - - console.log(`📏 [pageDimensions] Calculating page size:`, { - hasPage: !!page, - rawWidth: page?.width, - rawHeight: page?.height, - mediaBox: page?.mediaBox, - cropBox: page?.cropBox, - rotation: page?.rotation, - calculatedWidth: width, - calculatedHeight: height, - DEFAULT_PAGE_WIDTH, - DEFAULT_PAGE_HEIGHT, - commonFormats: { - "US Letter": "612 × 792 pt", - A4: "595 × 842 pt", - Legal: "612 × 1008 pt", - }, - }); - - return { width, height }; -}; - -export const createMergedElement = (group: TextGroup): PdfJsonTextElement => { - const reference = group.originalElements[0]; - const merged = cloneTextElement(reference); - merged.text = sanitizeParagraphText(group.text); - clearGlyphHints(merged); - if (reference.textMatrix && reference.textMatrix.length === 6) { - merged.textMatrix = [...reference.textMatrix]; - } - return merged; -}; - -const distributeTextAcrossElements = ( - text: string | undefined, - elements: PdfJsonTextElement[], -): boolean => { - if (elements.length === 0) { - return true; - } - - const normalizedText = sanitizeParagraphText(text); - const targetChars = Array.from(normalizedText); - if (targetChars.length === 0) { - elements.forEach((element) => { - element.text = ""; - clearGlyphHints(element); - }); - return true; - } - - const capacities = elements.map((element) => { - const originalText = element.text ?? ""; - const graphemeCount = Array.from(originalText).length; - return graphemeCount > 0 ? graphemeCount : 1; - }); - - let cursor = 0; - elements.forEach((element, index) => { - const remaining = targetChars.length - cursor; - let sliceLength = 0; - if (remaining > 0) { - if (index === elements.length - 1) { - sliceLength = remaining; - } else { - const capacity = Math.max(capacities[index], 1); - const minRemainingForRest = Math.max(elements.length - index - 1, 0); - sliceLength = Math.min( - capacity, - Math.max(remaining - minRemainingForRest, 1), - ); - } - } - - element.text = - sliceLength > 0 - ? targetChars.slice(cursor, cursor + sliceLength).join("") - : ""; - clearGlyphHints(element); - cursor += sliceLength; - }); - - elements.forEach((element) => { - if (element.text == null) { - element.text = ""; - } - }); - - return true; -}; - -const sliceElementsByLineCounts = ( - group: TextGroup, -): PdfJsonTextElement[][] => { - const counts = group.lineElementCounts; - if (!counts || counts.length === 0) { - if (!group.originalElements.length) { - return []; - } - return [group.originalElements]; - } - - const result: PdfJsonTextElement[][] = []; - let cursor = 0; - counts.forEach((count) => { - if (count <= 0) { - return; - } - const slice = group.originalElements.slice(cursor, cursor + count); - if (slice.length > 0) { - result.push(slice); - } - cursor += count; - }); - return result; -}; - -const rebuildParagraphLineElements = ( - group: TextGroup, -): PdfJsonTextElement[] | null => { - if (!group.text || !group.text.includes("\n")) { - return null; - } - - const lineTexts = splitParagraphIntoLines(group.text); - if (lineTexts.length === 0) { - return []; - } - - const lineElementGroups = sliceElementsByLineCounts(group); - if (!lineElementGroups.length) { - return null; - } - - const lineBaselines = lineElementGroups.map((elements) => { - for (const element of elements) { - const baseline = extractElementBaseline(element); - if (baseline !== null) { - return baseline; - } - } - return group.baseline ?? null; - }); - - const spacingFromBaselines = (() => { - for (let i = 1; i < lineBaselines.length; i += 1) { - const prev = lineBaselines[i - 1]; - const current = lineBaselines[i]; - if (prev !== null && current !== null) { - const diff = Math.abs(prev - current); - if (diff > 0) { - return diff; - } - } - } - return null; - })(); - - const spacing = - (group.lineSpacing && group.lineSpacing > 0 - ? group.lineSpacing - : spacingFromBaselines) ?? - Math.max(group.fontMatrixSize ?? group.fontSize ?? 12, 6) * 1.2; - - let direction = -1; - for (let i = 1; i < lineBaselines.length; i += 1) { - const prev = lineBaselines[i - 1]; - const current = lineBaselines[i]; - if (prev !== null && current !== null && Math.abs(prev - current) > 0.05) { - direction = current < prev ? -1 : 1; - break; - } - } - - const templateCount = lineElementGroups.length; - const lastTemplateIndex = Math.max(templateCount - 1, 0); - const rebuilt: PdfJsonTextElement[] = []; - - for (let index = 0; index < lineTexts.length; index += 1) { - const templateIndex = Math.min(index, lastTemplateIndex); - const templateElements = lineElementGroups[templateIndex]; - if (!templateElements || templateElements.length === 0) { - return null; - } - - const shiftSteps = index - templateIndex; - const delta = shiftSteps * spacing * direction; - const clones = shiftElementsBy(templateElements, delta); - const normalizedLine = sanitizeParagraphText(lineTexts[index]); - const distributed = distributeTextAcrossElements(normalizedLine, clones); - - if (!distributed) { - const primary = clones[0]; - primary.text = normalizedLine; - clearGlyphHints(primary); - for (let i = 1; i < clones.length; i += 1) { - clones[i].text = ""; - clearGlyphHints(clones[i]); - } - } - - rebuilt.push(...clones); - } - - return rebuilt; -}; - -export const restoreGlyphElements = ( - source: PdfJsonDocument, - groupsByPage: TextGroup[][], - imagesByPage: PdfJsonImageElement[][], - originalImagesByPage: PdfJsonImageElement[][], - forceMergedGroups: boolean = false, -): PdfJsonDocument => { - const updated = deepCloneDocument(source); - const pages = updated.pages ?? []; - - updated.pages = pages.map((page, pageIndex) => { - const groups = groupsByPage[pageIndex] ?? []; - const images = imagesByPage[pageIndex] ?? []; - const _baselineImages = originalImagesByPage[pageIndex] ?? []; - - if (!groups.length) { - return { - ...page, - imageElements: images.map(cloneImageElement), - }; - } - - const rebuiltElements: PdfJsonTextElement[] = []; - - groups.forEach((group) => { - if (group.text !== group.originalText) { - // Always try to rebuild paragraph lines if text has newlines - const paragraphElements = rebuildParagraphLineElements(group); - if (paragraphElements && paragraphElements.length > 0) { - rebuiltElements.push(...paragraphElements); - return; - } - // If no newlines or rebuilding failed, check if we should force merge - if (forceMergedGroups) { - rebuiltElements.push(createMergedElement(group)); - return; - } - const originalGlyphCount = group.originalElements.reduce( - (sum, element) => sum + countGraphemes(element.text ?? ""), - 0, - ); - const normalizedText = sanitizeParagraphText(group.text); - const targetGlyphCount = countGraphemes(normalizedText); - - if (targetGlyphCount !== originalGlyphCount) { - rebuiltElements.push(createMergedElement(group)); - return; - } - - const originals = group.originalElements.map(cloneTextElement); - const distributed = distributeTextAcrossElements( - normalizedText, - originals, - ); - if (distributed) { - rebuiltElements.push(...originals); - } else { - rebuiltElements.push(createMergedElement(group)); - } - return; - } - - rebuiltElements.push(...group.originalElements.map(cloneTextElement)); - }); - - return { - ...page, - textElements: rebuiltElements, - imageElements: images.map(cloneImageElement), - contentStreams: page.contentStreams ?? null, - }; - }); - - return updated; -}; - -const approxEqual = ( - a: number | null | undefined, - b: number | null | undefined, - tolerance = 0.25, -): boolean => { - const first = typeof a === "number" && Number.isFinite(a) ? a : 0; - const second = typeof b === "number" && Number.isFinite(b) ? b : 0; - return Math.abs(first - second) <= tolerance; -}; - -const arrayApproxEqual = ( - first: number[] | null | undefined, - second: number[] | null | undefined, - tolerance = 0.25, -): boolean => { - if (!first && !second) { - return true; - } - if (!first || !second) { - return false; - } - if (first.length !== second.length) { - return false; - } - for (let index = 0; index < first.length; index += 1) { - if (!approxEqual(first[index], second[index], tolerance)) { - return false; - } - } - return true; -}; - -const areImageElementsEqual = ( - current: PdfJsonImageElement, - original: PdfJsonImageElement, -): boolean => { - if (current === original) { - return true; - } - if (!current || !original) { - return false; - } - - const sameData = (current.imageData ?? null) === (original.imageData ?? null); - const sameFormat = - (current.imageFormat ?? null) === (original.imageFormat ?? null); - - return ( - sameData && - sameFormat && - approxEqual(current.x, original.x) && - approxEqual(current.y, original.y) && - approxEqual(current.width, original.width) && - approxEqual(current.height, original.height) && - approxEqual(current.left, original.left) && - approxEqual(current.right, original.right) && - approxEqual(current.top, original.top) && - approxEqual(current.bottom, original.bottom) && - (current.zOrder ?? null) === (original.zOrder ?? null) && - arrayApproxEqual(current.transform, original.transform) - ); -}; - -export const areImageListsDifferent = ( - current: PdfJsonImageElement[], - original: PdfJsonImageElement[], -): boolean => { - if (current.length !== original.length) { - return true; - } - for (let index = 0; index < current.length; index += 1) { - if (!areImageElementsEqual(current[index], original[index])) { - return true; - } - } - return false; -}; - -export const getDirtyPages = ( - groupsByPage: TextGroup[][], - imagesByPage: PdfJsonImageElement[][], - originalGroupsByPage: TextGroup[][], - originalImagesByPage: PdfJsonImageElement[][], -): boolean[] => { - return groupsByPage.map((groups, index) => { - // Check if any text was modified - const textDirty = groups.some((group) => group.text !== group.originalText); - - // Check if any groups were deleted by comparing with original groups - const originalGroups = originalGroupsByPage[index] ?? []; - const groupCountChanged = groups.length !== originalGroups.length; - - const imageDirty = areImageListsDifferent( - imagesByPage[index] ?? [], - originalImagesByPage[index] ?? [], - ); - - const isDirty = textDirty || groupCountChanged || imageDirty; - - if (groupCountChanged || textDirty) { - console.log(`📄 Page ${index} dirty check:`, { - textDirty, - groupCountChanged, - originalGroupsLength: originalGroups.length, - currentGroupsLength: groups.length, - imageDirty, - isDirty, - }); - } - - return isDirty; - }); -}; diff --git a/frontend/editor/src/core/tools/pdfTextEditor/pdfdoc/bytes.ts b/frontend/editor/src/core/tools/pdfTextEditor/pdfdoc/bytes.ts new file mode 100644 index 0000000000..48d85bc87e --- /dev/null +++ b/frontend/editor/src/core/tools/pdfTextEditor/pdfdoc/bytes.ts @@ -0,0 +1,145 @@ +/** + * Byte <-> latin1-string helpers for the raw-PDF layer. + * + * The surgery passes all want the file as a string so they can use the + * regex engine on it, but a 12 MB book costs real time to convert - and + * several passes run back to back over the same buffer. Memoise on the + * buffer identity so it converts once per document, not once per pass. + */ + +const cache = new WeakMap(); + +/** Chunked so `String.fromCharCode.apply` never blows the argument limit. */ +export function toLatin1(bytes: Uint8Array): string { + const hit = cache.get(bytes); + if (hit !== undefined) return hit; + let out = ""; + const CHUNK = 0x8000; + for (let i = 0; i < bytes.length; i += CHUNK) { + out += String.fromCharCode.apply( + null, + bytes.subarray( + i, + Math.min(i + CHUNK, bytes.length), + ) as unknown as number[], + ); + } + cache.set(bytes, out); + return out; +} + +export function fromLatin1(text: string): Uint8Array { + const out = new Uint8Array(text.length); + for (let i = 0; i < text.length; i += 1) out[i] = text.charCodeAt(i) & 0xff; + return out; +} + +export function concatBytes(parts: Uint8Array[]): Uint8Array { + let total = 0; + for (const p of parts) total += p.length; + const out = new Uint8Array(total); + let at = 0; + for (const p of parts) { + out.set(p, at); + at += p.length; + } + return out; +} + +/** + * Undo a PNG predictor (`/DecodeParms << /Predictor 12 ... >>`). + * + * Cross-reference streams almost always use predictor 12, so this is on the + * critical path for reading any PDF 1.5+ file. + */ +export function undoPngPredictor( + data: Uint8Array, + colors: number, + bpc: number, + columns: number, +): Uint8Array { + const bpp = Math.max(1, Math.ceil((colors * bpc) / 8)); + const rowLen = Math.ceil((colors * bpc * columns) / 8); + const rows = Math.floor(data.length / (rowLen + 1)); + const out = new Uint8Array(rows * rowLen); + let prev = new Uint8Array(rowLen); + for (let r = 0; r < rows; r += 1) { + const tag = data[r * (rowLen + 1)]; + const src = data.subarray(r * (rowLen + 1) + 1, (r + 1) * (rowLen + 1)); + const cur = new Uint8Array(rowLen); + for (let i = 0; i < rowLen; i += 1) { + const raw = src[i] ?? 0; + const left = i >= bpp ? cur[i - bpp] : 0; + const up = prev[i]; + const upLeft = i >= bpp ? prev[i - bpp] : 0; + switch (tag) { + case 0: + cur[i] = raw; + break; + case 1: + cur[i] = (raw + left) & 0xff; + break; + case 2: + cur[i] = (raw + up) & 0xff; + break; + case 3: + cur[i] = (raw + ((left + up) >> 1)) & 0xff; + break; + case 4: { + const p = left + up - upLeft; + const pa = Math.abs(p - left); + const pb = Math.abs(p - up); + const pc = Math.abs(p - upLeft); + const pred = pa <= pb && pa <= pc ? left : pb <= pc ? up : upLeft; + cur[i] = (raw + pred) & 0xff; + break; + } + default: + cur[i] = raw; + break; + } + } + out.set(cur, r * rowLen); + prev = cur; + } + return out; +} + +async function throughStream( + data: Uint8Array, + format: CompressionFormat, + kind: "inflate" | "deflate", +): Promise { + const src = new Blob([data as BlobPart]).stream(); + const piped = + kind === "inflate" + ? src.pipeThrough(new DecompressionStream(format)) + : src.pipeThrough(new CompressionStream(format)); + const buf = await new Response(piped).arrayBuffer(); + return new Uint8Array(buf); +} + +/** + * Inflate a `/FlateDecode` stream. PDF's Flate is zlib-wrapped, but real + * files in the wild ship raw deflate often enough that the fallback earns + * its keep - a single malformed stream must not fail a whole document. + */ +export async function inflate(data: Uint8Array): Promise { + try { + return await throughStream(data, "deflate", "inflate"); + } catch { + try { + return await throughStream(data, "deflate-raw", "inflate"); + } catch { + return null; + } + } +} + +export async function deflate(data: Uint8Array): Promise { + try { + return await throughStream(data, "deflate", "deflate"); + } catch { + return null; + } +} diff --git a/frontend/editor/src/core/tools/pdfTextEditor/pdfdoc/contentOps.ts b/frontend/editor/src/core/tools/pdfTextEditor/pdfdoc/contentOps.ts new file mode 100644 index 0000000000..4816db6382 --- /dev/null +++ b/frontend/editor/src/core/tools/pdfTextEditor/pdfdoc/contentOps.ts @@ -0,0 +1,180 @@ +/** + * Minimal content-stream tokeniser. + * + * Just enough structure to find operators and their operands, treating + * strings, dictionaries and arrays as opaque single tokens so a `(` inside a + * text string can never be mistaken for syntax. + */ + +const WHITESPACE = new Set([" ", "\t", "\r", "\n", "\f", "\0"]); +const DELIMITER = new Set(["(", ")", "<", ">", "[", "]", "{", "}", "/", "%"]); + +export interface ContentToken { + text: string; + start: number; + end: number; +} + +export interface ContentOp { + /** Operator name, e.g. `Tj`, `cm`, `sh`. */ + op: string; + operands: string[]; + /** Byte offset of the first operand (or the operator when it has none). */ + start: number; + /** Byte offset one past the operator. */ + end: number; +} + +/** + * Hand-scanned rather than regex-driven: PDF literal strings nest their + * parentheses, which no regular expression can follow, and getting that + * wrong turns the rest of a stream into nonsense. + */ +export function tokenize(content: string): ContentToken[] { + const out: ContentToken[] = []; + let i = 0; + while (i < content.length) { + const ch = content[i]; + if (WHITESPACE.has(ch)) { + i += 1; + continue; + } + const start = i; + if (ch === "%") { + while (i < content.length && content[i] !== "\n" && content[i] !== "\r") { + i += 1; + } + continue; + } + if (ch === "(") { + i += 1; + let depth = 1; + while (i < content.length && depth > 0) { + const c = content[i]; + if (c === "\\") { + i += 2; + continue; + } + if (c === "(") depth += 1; + else if (c === ")") depth -= 1; + i += 1; + } + } else if (ch === "<" && content[i + 1] === "<") { + i += 2; + } else if (ch === ">" && content[i + 1] === ">") { + i += 2; + } else if (ch === "<") { + const close = content.indexOf(">", i); + i = close < 0 ? content.length : close + 1; + } else if (ch === "/") { + i += 1; + while ( + i < content.length && + !WHITESPACE.has(content[i]) && + !DELIMITER.has(content[i]) + ) { + i += 1; + } + } else if (DELIMITER.has(ch)) { + i += 1; + } else { + while ( + i < content.length && + !WHITESPACE.has(content[i]) && + !DELIMITER.has(content[i]) + ) { + i += 1; + } + } + out.push({ text: content.slice(start, i), start, end: i }); + } + return out; +} + +const IS_OPERATOR = /^[A-Za-z'"][A-Za-z0-9*'"]*$/; +const NON_OPERATOR = new Set(["true", "false", "null", "R"]); + +/** Group tokens into operator invocations. */ +export function parseOps(content: string): ContentOp[] { + const tokens = tokenize(content); + const ops: ContentOp[] = []; + let operands: string[] = []; + let operandStart = -1; + let inlineImage = false; + for (const t of tokens) { + // Inline images carry raw binary between ID and EI that must not be + // lexed at all. + if (inlineImage) { + if (t.text !== "EI") continue; + inlineImage = false; + ops.push({ op: "EI", operands: [], start: t.start, end: t.end }); + operands = []; + operandStart = -1; + continue; + } + if (IS_OPERATOR.test(t.text) && !NON_OPERATOR.has(t.text)) { + ops.push({ + op: t.text, + operands, + start: operandStart < 0 ? t.start : operandStart, + end: t.end, + }); + if (t.text === "BI" || t.text === "ID") inlineImage = true; + operands = []; + operandStart = -1; + continue; + } + if (operandStart < 0) operandStart = t.start; + operands.push(t.text); + } + return ops; +} + +/** Operators that show text. */ +export const TEXT_SHOWING = new Set(["Tj", "TJ", "'", '"']); + +/** Path-painting operators, all of which also end the current path. */ +export const PATH_PAINTING = new Set([ + "S", + "s", + "f", + "F", + "f*", + "B", + "B*", + "b", + "b*", + "n", +]); + +/** Path construction operators. */ +export const PATH_CONSTRUCTION = new Set(["m", "l", "c", "v", "y", "h", "re"]); + +/** Operators that only mutate graphics state. */ +export const STATE_ONLY = new Set([ + "q", + "Q", + "cm", + "gs", + "w", + "J", + "j", + "M", + "d", + "ri", + "i", + "cs", + "CS", + "sc", + "scn", + "SC", + "SCN", + "g", + "G", + "rg", + "RG", + "k", + "K", + "W", + "W*", +]); diff --git a/frontend/editor/src/core/tools/pdfTextEditor/pdfdoc/passes/consolidateContents.ts b/frontend/editor/src/core/tools/pdfTextEditor/pdfdoc/passes/consolidateContents.ts new file mode 100644 index 0000000000..bf7799a03f --- /dev/null +++ b/frontend/editor/src/core/tools/pdfTextEditor/pdfdoc/passes/consolidateContents.ts @@ -0,0 +1,92 @@ +/** + * Merge multi-part `/Contents` arrays into a single stream, at load time. + * + * A page may legally split its content across several streams, and some + * producers do it every few kilobytes. The array is defined to be the + * concatenation of its parts, but PDFium's content generator rewrites only + * the parts that own a modified object - so after one edit the page holds a + * freshly written first chunk followed by stale continuation chunks that no + * longer make sense in that graphics state. The page then renders wrongly, + * or not at all, once it is reloaded. + * + * Collapsing the array before the document is ever opened removes the whole + * failure mode, and is invisible to everything else: one stream in, one + * stream out, same bytes of content. + */ +import { + concatBytes, + deflate, + fromLatin1, +} from "@app/tools/pdfTextEditor/pdfdoc/bytes"; +import { RawPdf, spliceValue } from "@app/tools/pdfTextEditor/pdfdoc/raw"; +import { + appendRevision, + plainObject, + streamObject, + type RevisionObject, +} from "@app/tools/pdfTextEditor/pdfdoc/revision"; + +export interface ConsolidateResult { + bytes: Uint8Array; + /** Page indices whose content streams were merged. */ + pages: number[]; +} + +export async function consolidateContents( + bytes: Uint8Array, +): Promise { + const pdf = await RawPdf.parse(bytes); + if (!pdf) return null; + if (pdf.encrypted) return null; + + const pageNums = pdf.pageNumbers(); + const objects: RevisionObject[] = []; + const merged: number[] = []; + let nextNum = pdf.highestObjectNumber + 1; + + for (let pageIndex = 0; pageIndex < pageNums.length; pageIndex += 1) { + const pageNum = pageNums[pageIndex]; + const body = pdf.objectBody(pageNum); + if (!body) continue; + const refs = pdf.contentRefs(body); + if (refs.length < 2) continue; + + const parts: Uint8Array[] = []; + let readable = true; + for (const ref of refs) { + const data = await pdf.streamData(ref); + if (!data) { + readable = false; + break; + } + parts.push(data); + // Parts join by concatenation, but a part ending mid-token would + // fuse with the next one's first token; a separator is always legal. + parts.push(fromLatin1("\n")); + } + if (!readable) continue; + + const span = pdf.valueSpan(body, "Contents"); + if (!span) continue; + + const raw = concatBytes(parts); + const packed = await deflate(raw); + const streamNum = nextNum; + nextNum += 1; + objects.push({ + num: streamNum, + body: packed + ? streamObject("<< /Filter /FlateDecode >>", packed) + : streamObject("<< >>", raw), + }); + objects.push({ + num: pageNum, + body: plainObject(spliceValue(body, span, `${streamNum} 0 R`)), + }); + merged.push(pageIndex); + } + + if (objects.length === 0) return null; + const out = appendRevision(pdf, objects); + return out ? { bytes: out, pages: merged } : null; +} diff --git a/frontend/editor/src/core/tools/pdfTextEditor/pdfdoc/passes/preserveShadings.ts b/frontend/editor/src/core/tools/pdfTextEditor/pdfdoc/passes/preserveShadings.ts new file mode 100644 index 0000000000..a16f1d23c6 --- /dev/null +++ b/frontend/editor/src/core/tools/pdfTextEditor/pdfdoc/passes/preserveShadings.ts @@ -0,0 +1,253 @@ +/** + * Keep vector gradients when a page is regenerated. + * + * PDFium's content generator serialises text, paths and images. A shading + * painted with the `sh` operator is none of those, so it is simply absent + * from the regenerated stream - the gradient disappears from every page the + * user edited, while the shading dictionaries and the resource names that + * point at them survive untouched in the saved file. + * + * That asymmetry is the repair: re-derive the original draw operators from + * the file as it was opened, and append them to the saved page as an extra + * content stream. The names still resolve, so the gradients come back as + * true vectors rather than a rasterised approximation. + * + * Rather than copying a byte range and hoping it is self-contained, the + * original stream is replayed through a filter that keeps everything + * affecting graphics state, neuters anything that would paint, and drops + * text and XObjects entirely. What is left reproduces the exact state each + * `sh` was drawn in, and paints nothing else. + */ +import { + deflate, + fromLatin1, + toLatin1, +} from "@app/tools/pdfTextEditor/pdfdoc/bytes"; +import { + PATH_PAINTING, + parseOps, + TEXT_SHOWING, +} from "@app/tools/pdfTextEditor/pdfdoc/contentOps"; +import { RawPdf, spliceValue } from "@app/tools/pdfTextEditor/pdfdoc/raw"; +import { + appendRevision, + plainObject, + streamObject, + type RevisionObject, +} from "@app/tools/pdfTextEditor/pdfdoc/revision"; + +const MARKED_CONTENT = new Set(["BDC", "BMC", "EMC", "MP", "DP"]); + +export type ShadingPhase = "all" | "background" | "foreground"; + +interface ExtractedShading { + /** Content-stream fragment that redraws every shading on the page. */ + content: string; + /** Resource names the fragment depends on, by resource category. */ + needs: { shading: string[]; extGState: string[]; pattern: string[] }; + /** True when the first shading precedes any text on the page. */ + isBackground: boolean; +} + +/** + * Replay a page's content, keeping only what is needed to redraw its + * shadings. Returns null when the page has none. + */ +export function extractShadingDraws( + content: string, + phase: ShadingPhase = "all", +): ExtractedShading | null { + const ops = parseOps(content); + const firstText = ops.findIndex((o) => TEXT_SHOWING.has(o.op)); + const wanted = (index: number): boolean => { + if (phase === "all" || firstText < 0) return true; + return phase === "background" ? index < firstText : index > firstText; + }; + const shIndexes = ops + .map((o, i) => (o.op === "sh" ? i : -1)) + .filter((i) => i >= 0 && wanted(i)); + if (shIndexes.length === 0) return null; + + const lastShading = shIndexes[shIndexes.length - 1]; + const shading: string[] = []; + const extGState: string[] = []; + const pattern: string[] = []; + const out: string[] = []; + let depth = 0; + let inText = false; + + for (let i = 0; i <= lastShading; i += 1) { + const op = ops[i]; + if (op.op === "BT") { + inText = true; + continue; + } + if (op.op === "ET") { + inText = false; + continue; + } + // Text positioning and font selection are scoped to the text object, so + // nothing inside BT..ET can influence a shading drawn outside it. + if (inText) continue; + if (op.op === "BI" || op.op === "ID" || op.op === "EI") continue; + // Marked content affects nothing a shading paints, and a BDC kept past + // the last `sh` without its EMC would swallow the rest of the page into + // an optional-content section. + if (MARKED_CONTENT.has(op.op)) continue; + // An XObject invocation could itself paint; the shadings it may contain + // live in the form's own stream, which regeneration never rewrites. + if (op.op === "Do") continue; + if (op.op === "sh") { + const name = op.operands[op.operands.length - 1]; + if (!name || name[0] !== "/") return null; + // Out-of-phase shadings still contribute nothing but must not paint. + if (!wanted(i)) continue; + shading.push(name.slice(1)); + out.push(`${name} sh`); + continue; + } + if (op.op === "gs") { + const name = op.operands[op.operands.length - 1]; + // A malformed `gs` would otherwise emit the literal token "undefined". + if (!name || name[0] !== "/") continue; + extGState.push(name.slice(1)); + out.push(`${name} gs`); + continue; + } + if (op.op === "scn" || op.op === "SCN") { + const last = op.operands[op.operands.length - 1]; + if (last && last[0] === "/") pattern.push(last.slice(1)); + out.push(`${op.operands.join(" ")} ${op.op}`); + continue; + } + if (PATH_PAINTING.has(op.op)) { + // Keep the path - a preceding `W` may be using it as a clip - but end + // it without painting, so only the shadings put ink on the page. + out.push("n"); + continue; + } + if (op.op === "q") depth += 1; + if (op.op === "Q") { + if (depth === 0) continue; + depth -= 1; + } + out.push(op.operands.length ? `${op.operands.join(" ")} ${op.op}` : op.op); + } + + // The fragment is concatenated with content that assumes a clean state. + for (let i = 0; i < depth; i += 1) out.push("Q"); + + if (shading.length === 0) return null; + return { + content: `q\n${out.join("\n")}\nQ\n`, + needs: { + shading: [...new Set(shading)], + extGState: [...new Set(extGState)], + pattern: [...new Set(pattern)], + }, + isBackground: firstText < 0 || shIndexes[0] < firstText, + }; +} + +/** True when `resources` declares `name` under `/Category`. */ +function resourceHasName( + pdf: RawPdf, + resources: string | null, + category: string, + name: string, +): boolean { + if (!resources) return false; + const sub = pdf.resolve(resources, category); + if (!sub) return false; + return new RegExp(`/${escapeName(name)}(?![^\\s/<>()\\[\\]{}%])`).test(sub); +} + +function escapeName(name: string): string { + return name.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); +} + +export interface PreserveShadingsOptions { + /** Page indices that were regenerated and may have lost their shadings. */ + pages: number[]; +} + +/** + * Re-inject shading draws into `savedBytes`, using `originalBytes` as the + * source of truth. Returns null when nothing could be applied safely - the + * caller keeps the saved bytes as they are. + */ +export async function preserveShadings( + savedBytes: Uint8Array, + originalBytes: Uint8Array, + options: PreserveShadingsOptions, +): Promise { + if (options.pages.length === 0) return null; + const original = await RawPdf.parse(originalBytes); + const saved = await RawPdf.parse(savedBytes); + if (!original || !saved) return null; + if (original.encrypted || saved.encrypted) return null; + + const objects: RevisionObject[] = []; + let nextNum = saved.highestObjectNumber + 1; + + for (const pageIndex of [...new Set(options.pages)].sort((a, b) => a - b)) { + const originalPageNum = original.pageNumberAt(pageIndex); + const savedPageNum = saved.pageNumberAt(pageIndex); + if (originalPageNum === null || savedPageNum === null) continue; + + const content = await original.pageContent(originalPageNum); + if (!content) continue; + const page = toLatin1(content); + const savedBody = saved.objectBody(savedPageNum); + if (!savedBody) continue; + const resources = saved.pageInherited(savedPageNum, "Resources"); + const existing = saved.contentRefs(savedBody); + if (existing.length === 0) continue; + const span = saved.valueSpan(savedBody, "Contents"); + if (!span) continue; + + // Split by phase: a gradient that sat under the text goes back under it, + // one that sat over it goes back over. A single fragment for the page put + // mid-page shadings on the wrong side of the content. + const before: number[] = []; + const after: number[] = []; + for (const phase of ["background", "foreground"] as const) { + const extracted = extractShadingDraws(page, phase); + if (!extracted) continue; + const resolvable = + extracted.needs.shading.every((n) => + resourceHasName(saved, resources, "Shading", n), + ) && + extracted.needs.extGState.every((n) => + resourceHasName(saved, resources, "ExtGState", n), + ) && + extracted.needs.pattern.every((n) => + resourceHasName(saved, resources, "Pattern", n), + ); + if (!resolvable) continue; + + const raw = fromLatin1(extracted.content); + const packed = await deflate(raw); + const streamNum = nextNum; + nextNum += 1; + objects.push({ + num: streamNum, + body: packed + ? streamObject("<< /Filter /FlateDecode >>", packed) + : streamObject("<< >>", raw), + }); + (phase === "background" ? before : after).push(streamNum); + } + if (before.length === 0 && after.length === 0) continue; + + const order = [...before, ...existing, ...after]; + const array = `[${order.map((n) => `${n} 0 R`).join(" ")}]`; + objects.push({ + num: savedPageNum, + body: plainObject(spliceValue(savedBody, span, array)), + }); + } + + if (objects.length === 0) return null; + return appendRevision(saved, objects); +} diff --git a/frontend/editor/src/core/tools/pdfTextEditor/pdfdoc/prepareForEditing.ts b/frontend/editor/src/core/tools/pdfTextEditor/pdfdoc/prepareForEditing.ts new file mode 100644 index 0000000000..84b8221512 --- /dev/null +++ b/frontend/editor/src/core/tools/pdfTextEditor/pdfdoc/prepareForEditing.ts @@ -0,0 +1,58 @@ +/** + * Load-time repairs, applied to the bytes before PDFium ever sees them. + * + * Each pass is optional and self-cancelling: it returns the original bytes + * unless it is certain it improved them. A document this cannot understand + * is opened exactly as it arrived, which is always a valid outcome. + */ +import { consolidateContents } from "@app/tools/pdfTextEditor/pdfdoc/passes/consolidateContents"; + +/** + * Above this the parse the passes need costs more than the repairs are worth, + * and the failure they guard against is rare in files this large. + */ +const MAX_PREPARE_BYTES = 96 * 1024 * 1024; + +export async function prepareForEditing( + bytes: Uint8Array, +): Promise { + if (bytes.length > MAX_PREPARE_BYTES) return bytes; + let out = bytes; + + // Scanned over the bytes, not a decoded string: this runs on every open, + // and converting a multi-megabyte file to a string just to answer "is + // there anything to do?" is pure latency on the load path. + if (hasContentsArray(out)) { + try { + const merged = await consolidateContents(out); + if (merged) out = merged.bytes; + } catch { + /* leaving the bytes alone is always safe */ + } + } + + return out; +} + +const CONTENTS = "/Contents"; +const WHITESPACE = new Set([0x20, 0x09, 0x0d, 0x0a, 0x0c, 0x00]); +const OPEN_BRACKET = 0x5b; + +/** True when some page's `/Contents` is an array rather than one stream. */ +function hasContentsArray(bytes: Uint8Array): boolean { + const first = CONTENTS.charCodeAt(0); + const limit = bytes.length - CONTENTS.length; + for (let i = 0; i < limit; i += 1) { + if (bytes[i] !== first) continue; + let k = 1; + while (k < CONTENTS.length && bytes[i + k] === CONTENTS.charCodeAt(k)) { + k += 1; + } + if (k < CONTENTS.length) continue; + let j = i + CONTENTS.length; + while (j < bytes.length && WHITESPACE.has(bytes[j])) j += 1; + if (bytes[j] === OPEN_BRACKET) return true; + i = j - 1; + } + return false; +} diff --git a/frontend/editor/src/core/tools/pdfTextEditor/pdfdoc/raw.ts b/frontend/editor/src/core/tools/pdfTextEditor/pdfdoc/raw.ts new file mode 100644 index 0000000000..8703775d35 --- /dev/null +++ b/frontend/editor/src/core/tools/pdfTextEditor/pdfdoc/raw.ts @@ -0,0 +1,686 @@ +/** + * A deliberately small read-only view over raw PDF bytes. + * + * PDFium's public API cannot express some of the repairs the editor needs + * (see `pdfdoc/passes/*`), so those passes work on the file itself. This is + * the shared substrate: one scan builds the object index, one walk builds + * the page list, and everything else is lookups. + * + * Everything here is best-effort by design. A file this cannot understand + * makes every accessor return null, and the calling pass leaves the bytes + * untouched rather than guessing. + */ +import { + fromLatin1, + inflate, + toLatin1, + undoPngPredictor, +} from "@app/tools/pdfTextEditor/pdfdoc/bytes"; + +/** No legitimate PDF object body runs longer than this. */ +const MAX_OBJECT_BYTES = 32 * 1024 * 1024; + +const WHITESPACE = new Set([" ", "\t", "\r", "\n", "\f", "\0"]); +const DELIMITER = new Set(["(", ")", "<", ">", "[", "]", "{", "}", "/", "%"]); + +/** Span of a dictionary entry's value inside an object body. */ +export interface ValueSpan { + /** Index of the first character of the value. */ + start: number; + /** Index one past the last character of the value. */ + end: number; + text: string; +} + +interface ObjectSource { + /** Generation the file declares for this object; almost always 0. */ + gen: number; + /** Byte offset of the object's `obj` keyword, for top-level objects. */ + offset?: number; + /** Pre-extracted body, for objects unpacked from an object stream. */ + body?: string; +} + +export class RawPdf { + readonly bytes: Uint8Array; + readonly src: string; + readonly rootNum: number; + /** True when the file's newest cross-reference section is a stream. */ + readonly usesXrefStream: boolean; + readonly startXref: number; + readonly trailerId: string | null; + /** True when the file has an /Encrypt dictionary. */ + readonly encrypted: boolean; + + private readonly objects: Map; + private readonly bodyCache = new Map(); + private pageNums: number[] | null = null; + /** Highest object number the file has ever used, across all revisions. */ + private highestObj: number; + + private constructor(init: { + bytes: Uint8Array; + src: string; + rootNum: number; + maxObjNum: number; + usesXrefStream: boolean; + startXref: number; + trailerId: string | null; + encrypted: boolean; + objects: Map; + }) { + this.bytes = init.bytes; + this.src = init.src; + this.rootNum = init.rootNum; + this.highestObj = init.maxObjNum; + this.usesXrefStream = init.usesXrefStream; + this.startXref = init.startXref; + this.trailerId = init.trailerId; + this.encrypted = init.encrypted; + this.objects = init.objects; + } + + static async parse(bytes: Uint8Array): Promise { + const src = toLatin1(bytes); + if (!src.startsWith("%PDF-") && src.indexOf("%PDF-") > 1024) return null; + + // ONE pass indexes every top-level object. A per-lookup scan of the + // whole file makes every caller quadratic, and several passes run per + // open - that is the difference between "opens instantly" and "the tab + // freezes on a large book". + const objects = new Map(); + let maxObjNum = 0; + const objRe = /(\d+)[\t\r\n\f ]+(\d+)[\t\r\n\f ]+obj\b/g; + for (let m = objRe.exec(src); m !== null; m = objRe.exec(src)) { + const before = m.index > 0 ? src[m.index - 1] : "\n"; + // "12 0 obj" must not match inside "912 0 obj". + if (before >= "0" && before <= "9") continue; + const num = parseInt(m[1], 10); + if (!Number.isFinite(num)) continue; + // Later revisions shadow earlier ones, so the last definition wins. + objects.set(num, { + gen: parseInt(m[2], 10) || 0, + offset: m.index + m[0].length, + }); + if (num > maxObjNum) maxObjNum = num; + } + if (objects.size === 0) return null; + + const startXref = (() => { + const at = src.lastIndexOf("startxref"); + if (at < 0) return -1; + const n = parseInt(src.slice(at + 9, at + 40).trim(), 10); + return Number.isFinite(n) ? n : -1; + })(); + const usesXrefStream = + startXref >= 0 && src.slice(startXref, startXref + 4) !== "xref"; + + // /Root lives in a trailer dictionary, or - for cross-reference-stream + // files, which have no `trailer` keyword at all - in the xref stream's + // own dictionary. Updated files chain trailers and the newest one may + // carry only /Size and /ID, so walk backwards until /Root turns up. + let rootNum = -1; + let trailerId: string | null = null; + for (let at = src.length; ;) { + at = src.lastIndexOf("trailer", at - 1); + if (at < 0) break; + const chunk = src.slice(at, at + 2048); + if (trailerId === null) { + const idm = chunk.match(/\/ID\s*(\[[^\]]*\])/); + if (idm) trailerId = idm[1]; + } + const rm = chunk.match(/\/Root\s+(\d+)\s+\d+\s+R/); + if (rm) { + rootNum = parseInt(rm[1], 10); + break; + } + if (at === 0) break; + } + if (rootNum < 0) { + const rm = src.match(/\/Root\s+(\d+)\s+\d+\s+R/); + if (rm) rootNum = parseInt(rm[1], 10); + } + if (trailerId === null) { + const idm = src.match(/\/ID\s*(\[[^\]]*\])/); + if (idm) trailerId = idm[1]; + } + if (rootNum < 0) return null; + + // Appended objects must number past every revision the file has, not + // just the newest one, so /Size takes the maximum found anywhere. + for (const m of src.matchAll(/\/Size\s+(\d+)/g)) { + const n = parseInt(m[1], 10); + if (Number.isFinite(n) && n - 1 > maxObjNum) maxObjNum = n - 1; + } + + const pdf = new RawPdf({ + bytes, + src, + rootNum, + maxObjNum, + usesXrefStream, + startXref, + trailerId, + encrypted: /\/Encrypt\s+\d+\s+\d+\s+R/.test(src), + objects, + }); + await pdf.indexObjectStreams(); + return pdf; + } + + /** + * Unpack `/Type /ObjStm` containers so objects stored inside them are + * reachable. In a PDF 1.5+ file most of the structure - page dictionaries + * included - lives in these, so without this step the passes see almost + * nothing. + */ + private async indexObjectStreams(): Promise { + const compressed = await this.compressedInNewestXref(); + const containers: number[] = []; + for (const num of this.objects.keys()) { + const body = this.objectBody(num); + if (body && /\/Type\s*\/ObjStm\b/.test(body)) containers.push(num); + } + for (const num of containers) { + const data = await this.streamData(num); + if (!data) continue; + const body = this.objectBody(num); + if (!body) continue; + const n = this.dictInt(body, "N"); + const first = this.dictInt(body, "First"); + if (n === null || first === null || first < 0) continue; + const text = toLatin1(data); + const header = text.slice(0, first).trim(); + const nums = header.length ? header.split(/\s+/).map(Number) : []; + for (let i = 0; i < n; i += 1) { + const objNum = nums[i * 2]; + const off = nums[i * 2 + 1]; + if (!Number.isFinite(objNum) || !Number.isFinite(off)) continue; + // A top-level definition usually comes from a later revision and + // wins - unless the newest xref says this object lives in a stream, + // in which case the top-level copy is the stale one. + if (this.objects.has(objNum) && !compressed.has(objNum)) continue; + const nextOff = i + 1 < n ? nums[i * 2 + 3] : data.length - first; + const end = Number.isFinite(nextOff) ? first + nextOff : text.length; + // Objects inside an object stream are generation 0 by definition. + this.objects.set(objNum, { + gen: 0, + body: text.slice(first + off, end), + }); + // The container scan above cached the stale top-level body. + this.bodyCache.delete(objNum); + if (objNum > this.highestObj) this.highestObj = objNum; + } + } + } + + // Object numbers the NEWEST cross-reference section stores inside an object + // stream (entry type 2). Empty for classic tables, which have no type 2. + private async compressedInNewestXref(): Promise> { + const out = new Set(); + if (this.startXref < 0 || !this.usesXrefStream) return out; + const header = /^(\d+)\s+(\d+)\s+obj\b/.exec( + this.src.slice(this.startXref, this.startXref + 64), + ); + if (!header) return out; + const num = parseInt(header[1], 10); + const body = this.objectBody(num); + if (!body || !/\/Type\s*\/XRef\b/.test(body)) return out; + const data = await this.streamData(num); + if (!data) return out; + + const wSpan = this.valueSpan(body, "W"); + const w = wSpan + ? [...wSpan.text.matchAll(/\d+/g)].map((m) => parseInt(m[0], 10)) + : []; + if (w.length < 3) return out; + const size = this.dictInt(body, "Size") ?? 0; + const indexSpan = this.valueSpan(body, "Index"); + const index = indexSpan + ? [...indexSpan.text.matchAll(/\d+/g)].map((m) => parseInt(m[0], 10)) + : [0, size]; + + const rowLen = w[0] + w[1] + w[2]; + if (rowLen <= 0) return out; + let at = 0; + for (let g = 0; g + 1 < index.length; g += 2) { + for (let k = 0; k < index[g + 1]; k += 1) { + if (at + rowLen > data.length) return out; + let type = 1; + if (w[0] > 0) { + type = 0; + for (let b = 0; b < w[0]; b += 1) type = (type << 8) | data[at + b]; + } + if (type === 2) out.add(index[g] + k); + at += rowLen; + } + } + return out; + } + + /** Object numbers appended by a revision must start above this. */ + get highestObjectNumber(): number { + return this.highestObj; + } + + /** Raw text of an object's body: everything between `obj` and `endobj`. */ + objectBody(num: number): string | null { + const cached = this.bodyCache.get(num); + if (cached !== undefined) return cached; + const entry = this.objects.get(num); + let body: string | null = null; + if (entry?.body !== undefined) { + body = entry.body; + } else if (entry?.offset !== undefined) { + // Bounded: an unterminated object in a hostile file would otherwise + // make every lookup scan to end of file. + const limit = Math.min(this.src.length, entry.offset + MAX_OBJECT_BYTES); + const end = this.src.indexOf("endobj", entry.offset); + body = end < 0 || end > limit ? null : this.src.slice(entry.offset, end); + } + this.bodyCache.set(num, body); + return body; + } + + /** Generation the file declares for an object, 0 when unknown. */ + generationOf(num: number): number { + return this.objects.get(num)?.gen ?? 0; + } + + hasObject(num: number): boolean { + return this.objects.has(num); + } + + /** Byte offset of the object body, or -1 when it lives in an ObjStm. */ + bodyOffset(num: number): number { + return this.objects.get(num)?.offset ?? -1; + } + + /** `/Key 12 0 R` -> 12. */ + dictRef(body: string, key: string): number | null { + const span = this.valueSpan(body, key); + if (!span) return null; + const m = span.text.match(/^(\d+)\s+\d+\s+R\b/); + return m ? parseInt(m[1], 10) : null; + } + + /** + * `/Key 42` -> 42, and null for anything else. Strict on purpose: a lax + * match reads `/Length 12 0 R` as the integer 12 and truncates the stream. + */ + dictInt(body: string, key: string): number | null { + const span = this.valueSpan(body, key); + if (!span) return null; + return /^-?\d+$/.test(span.text.trim()) ? parseInt(span.text, 10) : null; + } + + dictName(body: string, key: string): string | null { + const span = this.valueSpan(body, key); + if (!span) return null; + const m = span.text.match(/^\/([^\s/<>()[\]{}%]*)/); + return m ? m[1] : null; + } + + /** Follow `/Key n 0 R` when indirect, else return the direct value text. */ + resolve(body: string, key: string): string | null { + const span = this.valueSpan(body, key); + if (!span) return null; + const m = span.text.match(/^(\d+)\s+\d+\s+R\b/); + if (m) return this.objectBody(parseInt(m[1], 10)); + return span.text; + } + + /** + * Locate the value of `/Key` in the object's OUTERMOST dictionary. + * + * Depth-aware on purpose: a naive regex happily matches a `/Contents` + * buried in a nested annotation dictionary, and rewriting that instead of + * the page's own entry produces a file that opens but renders nothing. + */ + valueSpan(body: string, key: string): ValueSpan | null { + const open = body.indexOf("<<"); + if (open < 0) return null; + let i = open + 2; + let depth = 1; + while (i < body.length) { + const ch = body[i]; + if (ch === "%") { + while (i < body.length && body[i] !== "\n" && body[i] !== "\r") i += 1; + continue; + } + if (ch === "(") { + i = skipLiteralString(body, i); + continue; + } + if (ch === "<" && body[i + 1] === "<") { + depth += 1; + i += 2; + continue; + } + if (ch === ">" && body[i + 1] === ">") { + depth -= 1; + i += 2; + if (depth === 0) return null; + continue; + } + if (ch === "[" || ch === "]") { + i += 1; + continue; + } + if (ch === "/" && depth === 1) { + const nameEnd = scanNameEnd(body, i + 1); + if (body.slice(i + 1, nameEnd) === key) { + const start = skipWhitespace(body, nameEnd); + const end = scanValueEnd(body, start); + return { start, end, text: body.slice(start, end) }; + } + i = nameEnd; + continue; + } + i += 1; + } + return null; + } + + /** Decoded stream payload for an object, or null when unsupported. */ + async streamData(num: number): Promise { + const entry = this.objects.get(num); + if (!entry || entry.offset === undefined) return null; + const body = this.objectBody(num); + if (body === null) return null; + const kw = body.indexOf("stream"); + if (kw < 0) return null; + let dataStart = entry.offset + kw + "stream".length; + if (this.src[dataStart] === "\r") dataStart += 1; + if (this.src[dataStart] === "\n") dataStart += 1; + + let length = this.dictInt(body, "Length"); + if (length === null) { + const ref = this.dictRef(body, "Length"); + if (ref !== null) { + const lenBody = this.objectBody(ref); + const m = lenBody?.match(/-?\d+/); + if (m) length = parseInt(m[0], 10); + } + } + let dataEnd = length !== null && length >= 0 ? dataStart + length : -1; + // A wrong /Length is common enough in the wild that trusting it blindly + // truncates real content; verify against the endstream keyword. + const marker = this.src.indexOf("endstream", dataStart); + if (dataEnd < 0 || marker < 0 || dataEnd > marker) { + dataEnd = marker < 0 ? this.bytes.length : marker; + while ( + dataEnd > dataStart && + (this.src[dataEnd - 1] === "\n" || this.src[dataEnd - 1] === "\r") + ) { + dataEnd -= 1; + } + } + let data = this.bytes.subarray(dataStart, dataEnd); + + const filters = this.filterNames(body); + if (filters === null) return null; + if (filters.length === 0) return data; + if (filters.some((f) => f !== "FlateDecode")) return null; + for (let i = 0; i < filters.length; i += 1) { + const out = await inflate(data); + if (!out) return null; + data = out; + } + return this.applyPredictor(body, data); + } + + /** Null means "there is a filter here I cannot read", never "no filter". */ + private filterNames(body: string): string[] | null { + const span = this.valueSpan(body, "Filter"); + if (!span) return []; + // An indirect /Filter would otherwise look like no filter at all, and the + // still-compressed bytes would be handed back as decoded content. + if (/^\d+\s+\d+\s+R\b/.test(span.text)) return null; + if (span.text.startsWith("/")) { + const m = span.text.match(/^\/([^\s/<>()[\]{}%]*)/); + return m ? [m[1]] : []; + } + if (span.text.startsWith("[")) { + return [...span.text.matchAll(/\/([^\s/<>()[\]{}%]+)/g)].map((m) => m[1]); + } + return null; + } + + private applyPredictor(body: string, data: Uint8Array): Uint8Array | null { + const parms = this.valueSpan(body, "DecodeParms"); + if (!parms) return data; + if (/^\d+\s+\d+\s+R\b/.test(parms.text)) return null; + const dict = parms.text; + const int = (key: string, dflt: number): number => { + const m = dict.match(new RegExp(`/${key}\\s+(\\d+)`)); + return m ? parseInt(m[1], 10) : dflt; + }; + const predictor = int("Predictor", 1); + if (predictor < 10) return data; + return undoPngPredictor( + data, + int("Colors", 1), + int("BitsPerComponent", 8), + int("Columns", 1), + ); + } + + /** + * Object numbers of every page, in document order. + * + * Walked once and cached: re-walking from the root per page index turns a + * few-hundred-page document into a quadratic traversal. + */ + pageNumbers(): number[] { + if (this.pageNums) return this.pageNums; + const out: number[] = []; + const root = this.objectBody(this.rootNum); + const pagesNum = root ? this.dictRef(root, "Pages") : null; + const seen = new Set(); + const visit = (num: number, depth: number): void => { + if (depth > 64 || seen.has(num)) return; + seen.add(num); + const body = this.objectBody(num); + if (!body) return; + const type = this.dictName(body, "Type"); + if (type === "Page") { + out.push(num); + return; + } + const kids = this.valueSpan(body, "Kids"); + if (!kids) { + if (type === null) out.push(num); + return; + } + for (const m of kids.text.matchAll(/(\d+)\s+\d+\s+R/g)) { + visit(parseInt(m[1], 10), depth + 1); + } + }; + if (pagesNum !== null) visit(pagesNum, 0); + this.pageNums = out; + return out; + } + + pageNumberAt(pageIndex: number): number | null { + const pages = this.pageNumbers(); + return pageIndex >= 0 && pageIndex < pages.length ? pages[pageIndex] : null; + } + + /** + * Resolve a key on a page, walking `/Parent` for the inheritable ones + * (`/Resources`, `/MediaBox`, `/CropBox`, `/Rotate`). A page that inherits + * its resources is common, and treating it as having none silently + * disables every pass that needs them. + */ + pageInherited(pageNum: number, key: string): string | null { + let num: number | null = pageNum; + for (let depth = 0; num !== null && depth < 64; depth += 1) { + const body: string | null = this.objectBody(num); + if (!body) return null; + const direct = this.resolve(body, key); + if (direct !== null) return direct; + num = this.dictRef(body, "Parent"); + } + return null; + } + + /** Concatenated, decoded content stream(s) of a page. */ + async pageContent(pageNum: number): Promise { + const body = this.objectBody(pageNum); + if (!body) return null; + const refs = this.contentRefs(body); + if (refs.length === 0) return null; + const parts: Uint8Array[] = []; + for (const ref of refs) { + const data = await this.streamData(ref); + if (!data) return null; + parts.push(data); + parts.push(fromLatin1("\n")); + } + let total = 0; + for (const p of parts) total += p.length; + const out = new Uint8Array(total); + let at = 0; + for (const p of parts) { + out.set(p, at); + at += p.length; + } + return out; + } + + /** Object numbers backing a page's `/Contents`, in order. */ + contentRefs(pageBody: string): number[] { + const span = this.valueSpan(pageBody, "Contents"); + if (!span) return []; + if (span.text.startsWith("[")) { + return [...span.text.matchAll(/(\d+)\s+\d+\s+R/g)].map((m) => + parseInt(m[1], 10), + ); + } + const m = span.text.match(/^(\d+)\s+\d+\s+R\b/); + return m ? [parseInt(m[1], 10)] : []; + } +} + +/** + * Replace a dictionary entry's value, keeping the result lexable. + * + * Producers write `/Contents[8 0 R]` with no separator, so splicing a plain + * `11 0 R` straight in yields the single name token `/Contents11` and the + * page silently loses its content. + */ +export function spliceValue( + body: string, + span: ValueSpan, + replacement: string, +): string { + const before = body[span.start - 1]; + const needsGap = + before !== undefined && + !WHITESPACE.has(before) && + !DELIMITER.has(before) && + !WHITESPACE.has(replacement[0]) && + !DELIMITER.has(replacement[0]); + return ( + body.slice(0, span.start) + + (needsGap ? " " : "") + + replacement + + body.slice(span.end) + ); +} + +function skipWhitespace(text: string, at: number): number { + let i = at; + while (i < text.length && WHITESPACE.has(text[i])) i += 1; + return i; +} + +function scanNameEnd(text: string, at: number): number { + let i = at; + while ( + i < text.length && + !WHITESPACE.has(text[i]) && + !DELIMITER.has(text[i]) + ) { + i += 1; + } + return i; +} + +function skipLiteralString(text: string, at: number): number { + let i = at + 1; + let depth = 1; + while (i < text.length && depth > 0) { + const ch = text[i]; + if (ch === "\\") { + i += 2; + continue; + } + if (ch === "(") depth += 1; + else if (ch === ")") depth -= 1; + i += 1; + } + return i; +} + +/** End index of one complete object starting at `at`. */ +function scanValueEnd(text: string, at: number): number { + let i = at; + if (text[i] === "(") return skipLiteralString(text, i); + if (text[i] === "<" && text[i + 1] === "<") { + let depth = 0; + while (i < text.length) { + if (text[i] === "(") { + i = skipLiteralString(text, i); + continue; + } + if (text[i] === "<" && text[i + 1] === "<") { + depth += 1; + i += 2; + continue; + } + if (text[i] === ">" && text[i + 1] === ">") { + depth -= 1; + i += 2; + if (depth === 0) return i; + continue; + } + i += 1; + } + return i; + } + if (text[i] === "<") { + const close = text.indexOf(">", i); + return close < 0 ? text.length : close + 1; + } + if (text[i] === "[") { + let depth = 0; + while (i < text.length) { + if (text[i] === "(") { + i = skipLiteralString(text, i); + continue; + } + if (text[i] === "[") depth += 1; + else if (text[i] === "]") { + depth -= 1; + if (depth === 0) return i + 1; + } + i += 1; + } + return i; + } + // Bare token(s). An indirect reference is three tokens, so consume them + // together or `/Length 12 0 R` reads back as the integer 12. + const refMatch = /^\d+\s+\d+\s+R\b/.exec(text.slice(i)); + if (refMatch) return i + refMatch[0].length; + if (text[i] === "/") return scanNameEnd(text, i + 1); + while ( + i < text.length && + !WHITESPACE.has(text[i]) && + !DELIMITER.has(text[i]) + ) { + i += 1; + } + return i; +} diff --git a/frontend/editor/src/core/tools/pdfTextEditor/pdfdoc/revision.ts b/frontend/editor/src/core/tools/pdfTextEditor/pdfdoc/revision.ts new file mode 100644 index 0000000000..d6ac11cd18 --- /dev/null +++ b/frontend/editor/src/core/tools/pdfTextEditor/pdfdoc/revision.ts @@ -0,0 +1,156 @@ +/** + * Append an incremental revision to a PDF. + * + * Everything the raw-PDF passes do is expressed as "add these objects, + * shadow those ones" and appended to the end of the file. That is the only + * edit shape that leaves the original bytes untouched, which matters twice + * over: existing digital signatures keep verifying against their own + * revision, and a pass that turns out to be wrong can never destroy content + * that was already there. + */ +import { concatBytes, fromLatin1 } from "@app/tools/pdfTextEditor/pdfdoc/bytes"; +import type { RawPdf } from "@app/tools/pdfTextEditor/pdfdoc/raw"; + +export interface RevisionObject { + num: number; + /** Complete object body, everything that goes between `obj` and `endobj`. */ + body: Uint8Array; +} + +/** Build the body of a stream object from its dictionary and payload. */ +export function streamObject( + dictWithoutLength: string, + data: Uint8Array, +): Uint8Array { + const trimmed = dictWithoutLength.trim(); + const inner = trimmed.replace(/^<<|>>$/g, "").trim(); + const head = `<< ${inner} /Length ${data.length} >>\nstream\n`; + return concatBytes([fromLatin1(head), data, fromLatin1("\nendstream")]); +} + +export function plainObject(body: string): Uint8Array { + return fromLatin1(body); +} + +/** + * Serialise `objects` as a new revision appended to `pdf`. + * + * Returns null when the file's structure is not one this can extend safely - + * the caller then keeps the original bytes, which is always a valid outcome. + */ +export function appendRevision( + pdf: RawPdf, + objects: RevisionObject[], +): Uint8Array | null { + if (objects.length === 0) return pdf.bytes; + if (pdf.startXref < 0) return null; + + const sorted = [...objects].sort((a, b) => a.num - b.num); + for (let i = 1; i < sorted.length; i += 1) { + if (sorted[i].num === sorted[i - 1].num) return null; + } + + const parts: Uint8Array[] = [pdf.bytes]; + let at = pdf.bytes.length; + // PDFium and most producers end the file with `%%EOF` and no trailing + // newline; starting the revision on its own line keeps the appended + // objects lexable regardless. + const lead = fromLatin1("\n"); + parts.push(lead); + at += lead.length; + + const offsets = new Map(); + const gens = new Map(); + for (const obj of sorted) { + // Rewriting at generation 0 would orphan every reference that names the + // object's real generation. + const gen = pdf.generationOf(obj.num); + gens.set(obj.num, gen); + const header = fromLatin1(`${obj.num} ${gen} obj\n`); + offsets.set(obj.num, at); + parts.push(header, obj.body, fromLatin1("\nendobj\n")); + at += header.length + obj.body.length + "\nendobj\n".length; + } + + // Above everything in the batch, not just above the file: callers allocate + // their new objects from the same high-water mark, so basing this on that + // mark alone hands the xref stream a number a content stream already has - + // and the page then resolves its content to the cross-reference stream. + const xrefStreamNum = pdf.usesXrefStream + ? Math.max(pdf.highestObjectNumber, sorted[sorted.length - 1].num) + 1 + : -1; + const size = Math.max( + pdf.highestObjectNumber + 1, + sorted[sorted.length - 1].num + 1, + xrefStreamNum >= 0 ? xrefStreamNum + 1 : 0, + ); + const idPart = pdf.trailerId ? ` /ID ${pdf.trailerId}` : ""; + + if (xrefStreamNum < 0) { + const xrefAt = at; + let table = "xref\n"; + for (const [first, nums] of runsOf(sorted.map((o) => o.num))) { + table += `${first} ${nums.length}\n`; + for (const num of nums) { + const gen = String(gens.get(num) ?? 0).padStart(5, "0"); + table += `${String(offsets.get(num) ?? 0).padStart(10, "0")} ${gen} n \n`; + } + } + table += + `trailer\n<< /Size ${size} /Root ${pdf.rootNum} 0 R ` + + `/Prev ${pdf.startXref}${idPart} >>\n` + + `startxref\n${xrefAt}\n%%EOF\n`; + parts.push(fromLatin1(table)); + return concatBytes(parts); + } + + // Cross-reference-stream file: the update must be a stream too. A classic + // table whose /Prev points at a stream is not a structure readers accept. + offsets.set(xrefStreamNum, at); + const entryNums = [...sorted.map((o) => o.num), xrefStreamNum].sort( + (a, b) => a - b, + ); + const groups = [...runsOf(entryNums)]; + const index: number[] = []; + const rows: number[][] = []; + for (const [first, nums] of groups) { + index.push(first, nums.length); + for (const num of nums) { + const off = offsets.get(num) ?? 0; + rows.push([1, off, gens.get(num) ?? 0]); + } + } + const data = new Uint8Array(rows.length * 7); + rows.forEach((row, i) => { + const base = i * 7; + data[base] = row[0]; + data[base + 1] = (row[1] >>> 24) & 0xff; + data[base + 2] = (row[1] >>> 16) & 0xff; + data[base + 3] = (row[1] >>> 8) & 0xff; + data[base + 4] = row[1] & 0xff; + data[base + 5] = (row[2] >>> 8) & 0xff; + data[base + 6] = row[2] & 0xff; + }); + const dict = + `<< /Type /XRef /W [1 4 2] /Index [${index.join(" ")}] ` + + `/Size ${size} /Root ${pdf.rootNum} 0 R /Prev ${pdf.startXref}${idPart} >>`; + const xrefBody = streamObject(dict, data); + const header = fromLatin1(`${xrefStreamNum} 0 obj\n`); + parts.push(header, xrefBody, fromLatin1("\nendobj\n")); + parts.push(fromLatin1(`startxref\n${at}\n%%EOF\n`)); + return concatBytes(parts); +} + +/** Group sorted object numbers into consecutive runs for xref subsections. */ +function* runsOf(nums: number[]): Generator<[number, number[]]> { + let run: number[] = []; + for (const num of nums) { + if (run.length === 0 || num === run[run.length - 1] + 1) { + run.push(num); + continue; + } + yield [run[0], run]; + run = [num]; + } + if (run.length > 0) yield [run[0], run]; +} diff --git a/frontend/editor/src/core/tools/pdfTextEditor/pdfium/BackgroundSampler.ts b/frontend/editor/src/core/tools/pdfTextEditor/pdfium/BackgroundSampler.ts new file mode 100644 index 0000000000..3749e1db90 --- /dev/null +++ b/frontend/editor/src/core/tools/pdfTextEditor/pdfium/BackgroundSampler.ts @@ -0,0 +1,135 @@ +import type { WrappedPdfiumModule } from "@embedpdf/pdfium"; +import type { Page } from "@app/tools/pdfTextEditor/model/Page"; +import type { PageRect, RGBA } from "@app/tools/pdfTextEditor/types"; + +// Render the area of the page surrounding a text run and pick the dominant +// background color. +const MARGIN_POINTS = 6; +const SAMPLE_SCALE = 1.5; // bitmap resolution (px per PDF point) + +export interface SampleResult { + fill: RGBA; + /** True when the sampler found at least one consensus background pixel. */ + confident: boolean; +} + +export function sampleBackground( + m: WrappedPdfiumModule, + page: Page, + bounds: PageRect, +): SampleResult { + const fallback: RGBA = { r: 255, g: 255, b: 255, a: 255 }; + try { + // No flush needed: the render path draws from the in-memory object list. + // The rendered bitmap is CropBox/rotation (display) space; the run bounds + // are raw PDF. + const d = page.display; + const cs = [ + d.apply(bounds.x, bounds.y), + d.apply(bounds.x + bounds.width, bounds.y), + d.apply(bounds.x, bounds.y + bounds.height), + d.apply(bounds.x + bounds.width, bounds.y + bounds.height), + ]; + const dx0 = Math.min(...cs.map((c) => c.x)); + const dx1 = Math.max(...cs.map((c) => c.x)); + const dy0 = Math.min(...cs.map((c) => c.y)); + const dy1 = Math.max(...cs.map((c) => c.y)); + const left = Math.max(0, dx0 - MARGIN_POINTS); + const right = Math.min(page.width, dx1 + MARGIN_POINTS); + const top = Math.min(page.height, dy1 + MARGIN_POINTS); + const bottom = Math.max(0, dy0 - MARGIN_POINTS); + const widthPts = right - left; + const heightPts = top - bottom; + if (widthPts <= 1 || heightPts <= 1) + return { fill: fallback, confident: false }; + + const w = Math.max(8, Math.round(widthPts * SAMPLE_SCALE)); + const h = Math.max(8, Math.round(heightPts * SAMPLE_SCALE)); + + // Render the slice via PDFium. + const bitmapPtr = m.FPDFBitmap_Create(w, h, 1); + if (!bitmapPtr) return { fill: fallback, confident: false }; + try { + m.FPDFBitmap_FillRect(bitmapPtr, 0, 0, w, h, 0xffffffff); + // PDFium renders the WHOLE page sized to (pageW*scale, pageH*scale) + // at the bitmap's origin. We translate so our slice lands at 0,0. + const fullW = Math.round(page.width * SAMPLE_SCALE); + const fullH = Math.round(page.height * SAMPLE_SCALE); + const startX = -Math.round(left * SAMPLE_SCALE); + // CSS-style y: PDFium origin is page top-left in render coords. + const startY = -Math.round((page.height - top) * SAMPLE_SCALE); + // 0x01 = FPDF_ANNOT, 0x10 = FPDF_REVERSE_BYTE_ORDER (gives RGBA). + m.FPDF_RenderPageBitmap( + bitmapPtr, + page.pagePtr, + startX, + startY, + fullW, + fullH, + 0, + 0x01 | 0x10, + ); + + const bufferPtr = m.FPDFBitmap_GetBuffer(bitmapPtr); + const stride = m.FPDFBitmap_GetStride(bitmapPtr); + const heap = new Uint8Array( + (m.pdfium.wasmExports as unknown as { memory: WebAssembly.Memory }) + .memory.buffer, + bufferPtr, + stride * h, + ); + + // Sample the border rings (top, bottom, left, right) plus the + // four corners. Bucket by 4 bits per channel. + const buckets = new Map< + number, + { r: number; g: number; b: number; count: number } + >(); + const samples: Array<[number, number]> = []; + const ringWidth = 2; + for (let y = 0; y < h; y++) { + for (let x = 0; x < w; x++) { + const inTop = y < ringWidth; + const inBottom = y >= h - ringWidth; + const inLeft = x < ringWidth; + const inRight = x >= w - ringWidth; + if (!(inTop || inBottom || inLeft || inRight)) continue; + samples.push([x, y]); + } + } + for (const [x, y] of samples) { + const off = y * stride + x * 4; + const r = heap[off]; + const g = heap[off + 1]; + const b = heap[off + 2]; + const key = ((r >> 4) << 8) | ((g >> 4) << 4) | (b >> 4); + const bucket = buckets.get(key) ?? { r: 0, g: 0, b: 0, count: 0 }; + bucket.r += r; + bucket.g += g; + bucket.b += b; + bucket.count += 1; + buckets.set(key, bucket); + } + let best: { r: number; g: number; b: number; count: number } | null = + null; + for (const b of buckets.values()) { + if (!best || b.count > best.count) best = b; + } + if (!best || best.count === 0) + return { fill: fallback, confident: false }; + return { + fill: { + r: Math.round(best.r / best.count), + g: Math.round(best.g / best.count), + b: Math.round(best.b / best.count), + a: 255, + }, + confident: true, + }; + } finally { + m.FPDFBitmap_Destroy(bitmapPtr); + } + } catch { + return { fill: fallback, confident: false }; + } +} diff --git a/frontend/editor/src/core/tools/pdfTextEditor/pdfium/LineGrouper.ts b/frontend/editor/src/core/tools/pdfTextEditor/pdfium/LineGrouper.ts new file mode 100644 index 0000000000..c5e354d288 --- /dev/null +++ b/frontend/editor/src/core/tools/pdfTextEditor/pdfium/LineGrouper.ts @@ -0,0 +1,225 @@ +import type { Page } from "@app/tools/pdfTextEditor/model/Page"; +import type { TextRun } from "@app/tools/pdfTextEditor/model/TextRun"; + +/** Cluster adjacent text runs on a page into "line groups". */ +export interface LineGroupInfo { + /** The merged "virtual" run shown in the overlay. */ + representative: TextRun; + /** Original runs collapsed into this group, in left-to-right order. */ + members: TextRun[]; +} + +const BASELINE_TOLERANCE = 0.4; +// Two runs on the same baseline join the same line only when the horizontal gap +// between them is below this absolute cap. +const ABS_MAX_GAP_PT = 12; + +const WORD_GAP_MIN_RATIO = 0.2; +const FALLBACK_SPACE_UNIT_RATIO = 0.5; +const MIN_SPACE_UNIT_RATIO = 0.3; +const MAX_SPACE_UNIT_RATIO = 1; +const MULTI_SPACE_UNITS = 1.7; + +function junctionGapRatio(prev: TextRun, cur: TextRun): number { + const fontSize = Math.max(prev.fontSize, 4); + return (cur.bounds.x - (prev.bounds.x + prev.bounds.width)) / fontSize; +} + +function lineSpaceUnitRatio(members: TextRun[]): number { + const wordGaps: number[] = []; + for (let i = 1; i < members.length; i++) { + const ratio = junctionGapRatio(members[i - 1], members[i]); + if (ratio > WORD_GAP_MIN_RATIO) wordGaps.push(ratio); + } + if (wordGaps.length < 2) return FALLBACK_SPACE_UNIT_RATIO; + wordGaps.sort((a, b) => a - b); + const lowerMedian = wordGaps[Math.floor((wordGaps.length - 1) / 2)]; + return Math.min( + MAX_SPACE_UNIT_RATIO, + Math.max(MIN_SPACE_UNIT_RATIO, lowerMedian), + ); +} + +function spacesForGap(gapRatio: number, unitRatio: number): number { + if (gapRatio <= WORD_GAP_MIN_RATIO) return 0; + const units = gapRatio / unitRatio; + if (units < MULTI_SPACE_UNITS) return 1; + return Math.max(2, Math.round(units)); +} + +// True when a same-baseline cluster's glyphs overlap so heavily that it can't +// be normal running text. +function isDecorativeOverlap(members: TextRun[]): boolean { + if (members.length < 3) return false; + let overlapping = 0; + for (let i = 1; i < members.length; i++) { + const minAdvance = 0.12 * Math.max(members[i].fontSize, 4); + if (members[i].bounds.x - members[i - 1].bounds.x < minAdvance) { + overlapping += 1; + } + } + return overlapping / (members.length - 1) > 0.3; +} + +// A run that is just a list bullet (and a narrow glyph). +const BULLET_GLYPHS = /^[\s]*[•·∙▪●○◦‣⁃・‧°]+[\s]*$/; +function isBulletLead(run: TextRun): boolean { + return BULLET_GLYPHS.test(run.text) && run.bounds.width <= run.fontSize; +} + +// Sort one container's runs top-to-bottom / left-to-right and merge +// same-baseline, close-together runs into line groups. +function groupPartitionIntoLines(runs: TextRun[], out: LineGroupInfo[]): void { + const sorted = [...runs].sort((a, b) => { + const yDiff = b.matrix.f - a.matrix.f; + // Same-line band scaled to font size so a list bullet sitting a couple of + // points above its item still x-sorts onto the item's line. + const band = + BASELINE_TOLERANCE * Math.max(Math.min(a.fontSize, b.fontSize), 4); + if (Math.abs(yDiff) > Math.max(1, band)) return yDiff; + return a.bounds.x - b.bounds.x; + }); + + let current: LineGroupInfo | null = null; + for (const run of sorted) { + if (!current) { + current = { representative: run, members: [run] }; + out.push(current); + continue; + } + const ref = current.representative; + const baseDiff = Math.abs(run.matrix.f - ref.matrix.f); + const sameLine = baseDiff <= BASELINE_TOLERANCE * Math.max(ref.fontSize, 4); + const prev = current.members[current.members.length - 1]; + const gap = run.bounds.x - (prev.bounds.x + prev.bounds.width); + // The gap cap must scale with font size: an inter-word space in a 50pt + // heading is ~15-25pt, which a flat 12pt cap would treat as a line break. + const maxGap = Math.max(ABS_MAX_GAP_PT, 0.5 * Math.max(ref.fontSize, 4)); + // A leading bullet is indented from its item by more than an inter-word + // space; let the item attach across that wider indent. + const effMaxGap = isBulletLead(prev) + ? Math.max(maxGap, 2 * Math.max(ref.fontSize, 4)) + : maxGap; + // Reject joining a run that starts far to the LEFT of the previous run's + // right edge - a right-column run must never absorb the left column. + const minNegGap = 0.25 * Math.max(ref.fontSize, 4); + const close = gap <= effMaxGap && gap >= -minNegGap; + + if (sameLine && close) { + current.members.push(run); + } else { + current = { representative: run, members: [run] }; + out.push(current); + } + } +} + +export class LineGrouper { + /** Group a page's runs and store the result back onto the page. */ + static apply(page: Page): LineGroupInfo[] { + // Partition by form-xobject container BEFORE grouping. + const partitions = new Map(); + for (const run of page.runs) { + const key = run.containerPtr || 0; + const list = partitions.get(key); + if (list) list.push(run); + else partitions.set(key, [run]); + } + + const groups: LineGroupInfo[] = []; + for (const partition of partitions.values()) { + groupPartitionIntoLines(partition, groups); + } + + // Refine: a "line" whose glyphs heavily OVERLAP in x is not real running + // text. + const refined: LineGroupInfo[] = []; + for (const group of groups) { + if (group.members.length > 2 && isDecorativeOverlap(group.members)) { + for (const m of group.members) { + refined.push({ representative: m, members: [m] }); + } + } else { + refined.push(group); + } + } + groups.length = 0; + groups.push(...refined); + + // Mutate the representative's text/bounds to reflect the merged group and + // remember the underlying object pointers so ReplaceLineGroupCommand can. + for (const group of groups) { + if (group.members.length === 1) { + // A one-object line still needs its sub-run arrays. EditTextCommand's + // surgical path requires a non-empty mergedFromPtrs; without it even a + // two-character append detached the object and re-emitted the whole run + // from scratch, which is where real documents lost their text. + const only = group.members[0]; + group.representative.mergedFromPtrs = [only.pdfiumObjPtr]; + group.representative.mergedFromTexts = [only.text]; + group.representative.mergedFromBounds = [ + { x: only.bounds.x, right: only.bounds.x + only.bounds.width }, + ]; + group.representative.mergedFromCharStarts = [0]; + continue; + } + // Snapshot per-member texts and bounds BEFORE we mutate the + // representative. + const memberTexts = group.members.map((m) => m.text); + const memberBounds = group.members.map((m) => ({ + x: m.bounds.x, + right: m.bounds.x + m.bounds.width, + })); + // When the typesetter emitted a cursor jump instead of a literal space + // character, the two runs end up with content like ["Hello". + const parts: string[] = [memberTexts[0]]; + const memberCharStarts: number[] = [0]; + let cumulativeLen = memberTexts[0].length; + const spaceUnitRatio = lineSpaceUnitRatio(group.members); + for (let i = 1; i < group.members.length; i++) { + const prev = group.members[i - 1]; + const cur = group.members[i]; + const prevTail = memberTexts[i - 1].slice(-1); + const curHead = memberTexts[i].slice(0, 1); + const extraSpaces = spacesForGap( + junctionGapRatio(prev, cur), + spaceUnitRatio, + ); + const prevEndsInSpace = /\s/.test(prevTail); + const curStartsWithSpace = /\s/.test(curHead); + const alreadyHave = + (prevEndsInSpace ? 1 : 0) + (curStartsWithSpace ? 1 : 0); + const toInsert = Math.max(0, extraSpaces - alreadyHave); + if (toInsert > 0) { + parts.push(" ".repeat(toInsert)); + cumulativeLen += toInsert; + } + memberCharStarts.push(cumulativeLen); + parts.push(memberTexts[i]); + cumulativeLen += memberTexts[i].length; + } + const joined = parts.join(""); + const last = group.members[group.members.length - 1]; + const left = group.representative.bounds.x; + const right = last.bounds.x + last.bounds.width; + group.representative.text = joined; + group.representative.bounds = { + ...group.representative.bounds, + x: left, + width: Math.max(group.representative.bounds.width, right - left), + }; + // Per-sub-run texts + bounds so EditTextCommand's pure-deletion + // optimization can map joined-text chars back to their source. + group.representative.mergedFromTexts = memberTexts; + group.representative.mergedFromBounds = memberBounds; + group.representative.mergedFromCharStarts = memberCharStarts; + group.representative.mergedFromPtrs = group.members.map( + (m) => m.pdfiumObjPtr, + ); + } + + // Replace the page's runs with just the representatives. + page.setRuns(groups.map((g) => g.representative)); + return groups; + } +} diff --git a/frontend/editor/src/core/tools/pdfTextEditor/pdfium/ParagraphGrouper.ts b/frontend/editor/src/core/tools/pdfTextEditor/pdfium/ParagraphGrouper.ts new file mode 100644 index 0000000000..cc4dcdbcc8 --- /dev/null +++ b/frontend/editor/src/core/tools/pdfTextEditor/pdfium/ParagraphGrouper.ts @@ -0,0 +1,327 @@ +import type { Page } from "@app/tools/pdfTextEditor/model/Page"; +import type { + ParagraphLineSlot, + TextRun, +} from "@app/tools/pdfTextEditor/model/TextRun"; + +/** Cluster consecutive `LineGroup` representatives into "paragraphs". */ +const MIN_LINE_FACTOR = 0.6; +const MAX_LINE_FACTOR = 2.0; +const MEDIAN_TOLERANCE = 0.25; +const MARGIN_INDENT_RIGHT = 12; +const MARGIN_OUTDENT_LEFT = 2; +// Two runs are "side by side" (column peers) when their baselines are within +// this fraction of a line and a horizontal gap this wide sits between them. +const COLUMN_BASELINE_FRAC = 0.6; +const COLUMN_MIN_GAP_PT = 24; +// Left-edge clustering tolerance when splitting runs into columns. +const COLUMN_LEFT_TOLERANCE = 14; + +export interface ParagraphInfo { + representative: TextRun; + members: TextRun[]; +} + +export class ParagraphGrouper { + static apply(page: Page): ParagraphInfo[] { + const allLines = [...page.runs]; + const paragraphs: ParagraphInfo[] = []; + + // Columns first: a reading-order sort across the whole page interleaves + // side-by-side columns into one bogus paragraph. + for (const column of segmentColumns(allLines)) { + const sorted = column.sort((a, b) => { + const yDiff = b.matrix.f - a.matrix.f; + if (Math.abs(yDiff) > 0.5) return yDiff; + return a.bounds.x - b.bounds.x; + }); + groupColumnLines(sorted, paragraphs); + } + + // Fold member bounds + text into the representative and drop the member + // runs from the page so the editor sees one overlay per paragraph. + for (const para of paragraphs) { + if (para.members.length === 1) continue; + const rep = para.representative; + + // Snapshot per-line sub-run arrays BEFORE the rep.text mutation + // overwrites members[0]'s state. + const memberLineTexts = para.members.map((m) => m.text); + const slots = buildLineSlots(para.members, memberLineTexts); + + const joinedText = memberLineTexts.join("\n"); + const minX = Math.min(...para.members.map((m) => m.bounds.x)); + const maxRight = Math.max( + ...para.members.map((m) => m.bounds.x + m.bounds.width), + ); + const topY = Math.max( + ...para.members.map((m) => m.bounds.y + m.bounds.height), + ); + const bottomY = Math.min(...para.members.map((m) => m.bounds.y)); + rep.text = joinedText; + rep.bounds = { + x: minX, + y: bottomY, + width: maxRight - minX, + height: topY - bottomY, + }; + // Stash per-line metadata on the representative so the React layer can + // render with the correct line-height and the edit command can emit one. + rep.paragraphLineHeight = computeMedianLineHeight(para.members); + rep.paragraphMemberPtrs = para.members.map((m) => m.pdfiumObjPtr); + rep.paragraphMemberContainers = para.members.map((m) => m.containerPtr); + rep.paragraphMemberFs = para.members.map((m) => m.matrix.f); + // Track every leaf ptr so EditTextCommand can remove the original + // sub-words. + const leafPtrs: number[] = []; + const leafContainers: number[] = []; + for (const m of para.members) { + const leaves = + m.mergedFromPtrs.length > 0 + ? m.mergedFromPtrs + : m.pdfiumObjPtr + ? [m.pdfiumObjPtr] + : []; + for (const p of leaves) { + leafPtrs.push(p); + leafContainers.push(m.containerPtr); + } + } + rep.paragraphLeafPtrs = leafPtrs; + rep.paragraphLeafContainers = leafContainers; + rep.paragraphLineSlots = slots; + } + + page.setRuns(paragraphs.map((p) => p.representative)); + return paragraphs; + } +} + +/** Build a `ParagraphLineSlot[]` from the paragraph's member runs. */ +export function buildLineSlots( + members: TextRun[], + lineTexts: string[], +): ParagraphLineSlot[] { + const slots: ParagraphLineSlot[] = []; + let cursor = 0; + for (let i = 0; i < members.length; i++) { + const m = members[i]; + const text = lineTexts[i]; + const len = text.length; + // A line that LineGrouper merged from several source objects already has + // per-sub-run arrays. + const hasSubRuns = m.mergedFromPtrs.length > 0; + const mergedFromPtrs = hasSubRuns + ? [...m.mergedFromPtrs] + : m.pdfiumObjPtr + ? [m.pdfiumObjPtr] + : []; + const mergedFromTexts = hasSubRuns ? [...m.mergedFromTexts] : [text]; + const mergedFromBounds = hasSubRuns + ? m.mergedFromBounds.map((b) => ({ ...b })) + : [{ x: m.bounds.x, right: m.bounds.x + m.bounds.width }]; + const mergedFromCharStarts = hasSubRuns ? [...m.mergedFromCharStarts] : [0]; + slots.push({ + startChar: cursor, + endChar: cursor + len, + baselineY: m.matrix.f, + matrixE: m.matrix.e, + containerPtr: m.containerPtr, + fontId: m.fontId, + fontSize: m.fontSize, + fontSubset: m.fontSubset, + mergedFromPtrs, + mergedFromTexts, + mergedFromBounds, + mergedFromCharStarts, + }); + // +1 for the synthesised "\n" between lines (no separator after the + // last line). + cursor += len + (i < members.length - 1 ? 1 : 0); + } + return slots; +} + +/** One visual line's worth of slot source. */ +export interface LineSlotDescriptor { + text: string; + baselineY: number; + matrixE: number; + containerPtr: number; + fontId: string; + fontSize: number; + fontSubset: boolean; + mergedFromPtrs: number[]; + mergedFromTexts: string[]; + mergedFromBounds: Array<{ x: number; right: number }>; + mergedFromCharStarts: number[]; +} + +// Same cursor walk as `buildLineSlots` but pulls each line's `mergedFrom*` +// directly from a descriptor instead of a TextRun. +export function buildLineSlotsFromDescriptors( + descs: LineSlotDescriptor[], +): ParagraphLineSlot[] { + const slots: ParagraphLineSlot[] = []; + let cursor = 0; + for (let i = 0; i < descs.length; i++) { + const d = descs[i]; + const len = d.text.length; + slots.push({ + startChar: cursor, + endChar: cursor + len, + baselineY: d.baselineY, + matrixE: d.matrixE, + containerPtr: d.containerPtr, + fontId: d.fontId, + fontSize: d.fontSize, + fontSubset: d.fontSubset, + mergedFromPtrs: [...d.mergedFromPtrs], + mergedFromTexts: [...d.mergedFromTexts], + mergedFromBounds: d.mergedFromBounds.map((b) => ({ ...b })), + mergedFromCharStarts: [...d.mergedFromCharStarts], + }); + cursor += len + (i < descs.length - 1 ? 1 : 0); + } + return slots; +} + +// A run's visual font identity for grouping: family + rounded size. +// `run.fontId` is `pdf::`. +function fontKey(run: TextRun): string { + const family = run.fontId.slice(run.fontId.lastIndexOf(":") + 1); + return `${family}@${Math.round(run.fontSize)}`; +} + +/** Split a page's line-runs into columns. */ +function segmentColumns(lines: TextRun[]): TextRun[][] { + if (lines.length < 4) return [lines]; + + // Detect side-by-side peers. + let sideBySide = 0; + for (let i = 0; i < lines.length && sideBySide < 2; i++) { + for (let j = i + 1; j < lines.length; j++) { + const a = lines[i]; + const b = lines[j]; + const baseTol = + COLUMN_BASELINE_FRAC * Math.min(a.fontSize, b.fontSize || a.fontSize); + if (Math.abs(a.matrix.f - b.matrix.f) > baseTol) continue; + const aRight = a.bounds.x + a.bounds.width; + const bRight = b.bounds.x + b.bounds.width; + const gap = + a.bounds.x > b.bounds.x ? a.bounds.x - bRight : b.bounds.x - aRight; + if (gap >= COLUMN_MIN_GAP_PT) { + sideBySide += 1; + break; + } + } + } + if (sideBySide < 2) return [lines]; + + // Cluster left edges into column buckets. + const edges = lines.map((l) => l.bounds.x).sort((a, b) => a - b); + const centers: number[] = []; + for (const e of edges) { + const last = centers[centers.length - 1]; + if (last === undefined || e - last > COLUMN_LEFT_TOLERANCE) centers.push(e); + } + if (centers.length < 2) return [lines]; + + const columns: TextRun[][] = centers.map(() => []); + for (const line of lines) { + let best = 0; + let bestDist = Infinity; + for (let i = 0; i < centers.length; i++) { + const d = Math.abs(line.bounds.x - centers[i]); + if (d < bestDist) { + bestDist = d; + best = i; + } + } + columns[best].push(line); + } + return columns.filter((c) => c.length > 0); +} + +// Sequentially group one column's already-sorted (top-to-bottom) lines into +// paragraphs, appending each paragraph to `out`. +function groupColumnLines(sorted: TextRun[], out: ParagraphInfo[]): void { + let current: ParagraphInfo | null = null; + let currentDeltas: number[] = []; + let currentLeftEdge = 0; + + for (const line of sorted) { + if (!current) { + current = { representative: line, members: [line] }; + out.push(current); + currentDeltas = []; + currentLeftEdge = line.bounds.x; + continue; + } + const prev = current.members[current.members.length - 1]; + const sameFont = fontKey(prev) === fontKey(line); + const sameColor = + prev.fill.r === line.fill.r && + prev.fill.g === line.fill.g && + prev.fill.b === line.fill.b; + const baselineDelta = prev.matrix.f - line.matrix.f; + + let lineHeightOk: boolean; + if (currentDeltas.length === 0) { + lineHeightOk = + baselineDelta >= MIN_LINE_FACTOR * line.fontSize && + baselineDelta <= MAX_LINE_FACTOR * line.fontSize; + } else { + const med = median(currentDeltas); + const tol = MEDIAN_TOLERANCE * med; + lineHeightOk = baselineDelta >= med - tol && baselineDelta <= med + tol; + } + + const deltaFromLeft = line.bounds.x - currentLeftEdge; + const leftOk = + deltaFromLeft >= -MARGIN_OUTDENT_LEFT && + deltaFromLeft <= MARGIN_INDENT_RIGHT; + + if (sameFont && sameColor && lineHeightOk && leftOk) { + current.members.push(line); + currentDeltas.push(baselineDelta); + if (line.bounds.x < currentLeftEdge) currentLeftEdge = line.bounds.x; + } else { + current = { representative: line, members: [line] }; + out.push(current); + currentDeltas = []; + currentLeftEdge = line.bounds.x; + } + } +} + +function median(values: number[]): number { + if (values.length === 0) return 0; + const sorted = [...values].sort((a, b) => a - b); + const mid = sorted.length >> 1; + return sorted.length % 2 === 0 + ? (sorted[mid - 1] + sorted[mid]) / 2 + : sorted[mid]; +} + +function computeMedianLineHeight(members: TextRun[]): number { + if (members.length < 2) return members[0].fontSize * 1.2; + return medianLineHeightFromBaselines( + members.map((m) => m.matrix.f), + members[0].fontSize, + ); +} + +// Median of consecutive baseline deltas; falls back to 1.2em when there is +// fewer than one delta. +export function medianLineHeightFromBaselines( + baselines: number[], + fallbackFontSize: number, +): number { + if (baselines.length < 2) return fallbackFontSize * 1.2; + const deltas: number[] = []; + for (let i = 1; i < baselines.length; i++) { + deltas.push(baselines[i - 1] - baselines[i]); + } + return median(deltas); +} diff --git a/frontend/editor/src/core/tools/pdfTextEditor/pdfium/PdfiumAnnotationReader.ts b/frontend/editor/src/core/tools/pdfTextEditor/pdfium/PdfiumAnnotationReader.ts new file mode 100644 index 0000000000..9e87b5d51b --- /dev/null +++ b/frontend/editor/src/core/tools/pdfTextEditor/pdfium/PdfiumAnnotationReader.ts @@ -0,0 +1,92 @@ +import type { WrappedPdfiumModule } from "@embedpdf/pdfium"; +import { + type AnnotationBox, + annotationKindFor, +} from "@app/tools/pdfTextEditor/model/AnnotationBox"; +import type { Page } from "@app/tools/pdfTextEditor/model/Page"; + +// The canvas renders with FPDF_ANNOT, but the editor model walks page objects +// only - so FreeText/widget/stamp text is visible and completely uneditable. +// Reading the boxes lets the UI outline them and say why. + +interface AnnotModule { + FPDFPage_GetAnnotCount?: (page: number) => number; + FPDFPage_GetAnnot?: (page: number, index: number) => number; + FPDFPage_CloseAnnot?: (annot: number) => void; + FPDFAnnot_GetSubtype?: (annot: number) => number; + FPDFAnnot_GetRect?: (annot: number, rect: number) => boolean; + EPDFAnnot_GetRect?: (annot: number, rect: number) => boolean; +} + +/** Hard cap so a pathological page can't stall the reader. */ +const MAX_ANNOTS = 2000; + +export class PdfiumAnnotationReader { + static populate(m: WrappedPdfiumModule, page: Page): void { + const mod = m as unknown as AnnotModule; + if ( + !mod.FPDFPage_GetAnnotCount || + !mod.FPDFPage_GetAnnot || + !mod.FPDFAnnot_GetSubtype || + !mod.FPDFPage_CloseAnnot + ) { + page.setAnnotations([]); + return; + } + const getRect = mod.EPDFAnnot_GetRect ?? mod.FPDFAnnot_GetRect; + if (!getRect) { + page.setAnnotations([]); + return; + } + + let count = 0; + try { + count = mod.FPDFPage_GetAnnotCount(page.pagePtr); + } catch { + page.setAnnotations([]); + return; + } + + const out: AnnotationBox[] = []; + const rectBuf = m.pdfium.wasmExports.malloc(4 * 4); + try { + for (let i = 0; i < Math.min(count, MAX_ANNOTS); i++) { + const annot = mod.FPDFPage_GetAnnot(page.pagePtr, i); + if (!annot) continue; + try { + const kind = annotationKindFor(mod.FPDFAnnot_GetSubtype(annot)); + if (!kind) continue; + if (!getRect(annot, rectBuf)) continue; + const left = m.pdfium.getValue(rectBuf, "float"); + const top = m.pdfium.getValue(rectBuf + 4, "float"); + const right = m.pdfium.getValue(rectBuf + 8, "float"); + const bottom = m.pdfium.getValue(rectBuf + 12, "float"); + const x = Math.min(left, right); + const y = Math.min(top, bottom); + const width = Math.abs(right - left); + const height = Math.abs(top - bottom); + // Degenerate rects (hidden widgets) would draw a dot over the page. + if (!(width > 1 && height > 1)) continue; + if ( + !Number.isFinite(x) || + !Number.isFinite(y) || + !Number.isFinite(width) || + !Number.isFinite(height) + ) { + continue; + } + out.push({ + id: `p${page.index}-annot-${i}`, + kind, + rect: { x, y, width, height }, + }); + } finally { + mod.FPDFPage_CloseAnnot(annot); + } + } + } finally { + m.pdfium.wasmExports.free(rectBuf); + } + page.setAnnotations(out); + } +} diff --git a/frontend/editor/src/core/tools/pdfTextEditor/pdfium/PdfiumModelSync.ts b/frontend/editor/src/core/tools/pdfTextEditor/pdfium/PdfiumModelSync.ts new file mode 100644 index 0000000000..62ea27a5fb --- /dev/null +++ b/frontend/editor/src/core/tools/pdfTextEditor/pdfium/PdfiumModelSync.ts @@ -0,0 +1,126 @@ +import type { EditorDocument } from "@app/tools/pdfTextEditor/model/EditorDocument"; +import { Page } from "@app/tools/pdfTextEditor/model/Page"; +import type { TextRun } from "@app/tools/pdfTextEditor/model/TextRun"; +import { PdfiumTextReader } from "@app/tools/pdfTextEditor/pdfium/PdfiumTextReader"; +import type { GroupingMode } from "@app/tools/pdfTextEditor/types"; + +// Re-read a page from PDFium and fold the result onto the EXISTING run objects +// instead of replacing them. +// +// `PdfiumTextReader.populate` mints fresh `TextRun`s with fresh ids, so calling +// it after a commit would invalidate every id the selection, the undo stack and +// React's keys are holding - which is exactly why the model is hand-patched by +// each command today. Matching the re-read runs back onto the live ones by +// PDFium object pointer keeps identity stable, so the engine can be the source +// of truth for geometry without anything downstream noticing. + +/** Every PDFium pointer that backs a run, in the order the reader emits them. */ +function memberPtrsOf(run: TextRun): number[] { + if (run.paragraphLeafPtrs.length > 0) return run.paragraphLeafPtrs; + if (run.mergedFromPtrs.length > 0) return run.mergedFromPtrs; + return run.pdfiumObjPtr ? [run.pdfiumObjPtr] : []; +} + +// Engine-owned geometry. Text and font identity stay with the model. +// +// Deliberately positions ONLY, not bounds or the matrix. This refresh fires +// 600ms after the last keystroke, which is usually still mid-edit, so adopting +// the engine's box would resize the field under the user's caret - and would +// also overwrite the deliberate "focused box grows past its text so the caret +// has room" behaviour. Bounds adoption becomes safe once the overlay is +// destroyed on blur (issue 3c), which is why the doc orders 3a -> 3b -> 3c. +function adoptGeometry(target: TextRun, fresh: TextRun): boolean { + // Pen positions are what the overlay paints against. Only adopt them when + // they describe the SAME string, or the overlay would lay this run's glyphs + // out against another text's advances. + if (fresh.charPositionsKey !== target.positionsKey()) return false; + target.charStartsX = fresh.charStartsX; + target.charEndsX = fresh.charEndsX; + target.charPositionsKey = fresh.charPositionsKey; + target.charSpacingPt = fresh.charSpacingPt; + return true; +} + +export interface ModelSyncResult { + /** True when any live run's geometry actually moved. */ + changed: boolean; + matched: number; + /** Live runs the re-read no longer sees (their objects went away). */ + unmatched: number; + /** Runs the re-read found that the model has no id for. */ + appeared: number; +} + +export class PdfiumModelSync { + // Re-read `page` and mutate its existing runs in place. Runs are matched by + // shared PDFium object pointers, so ids survive. + static resyncPage( + doc: EditorDocument, + page: Page, + mode: GroupingMode, + ): ModelSyncResult { + const result: ModelSyncResult = { + changed: false, + matched: 0, + unmatched: 0, + appeared: 0, + }; + if (!page.loaded || page.runs.length === 0) return result; + + // Push pending object edits into the content stream first: the text page + // the reader opens is built from the CURRENT stream. + page.flushGenerate(doc.module); + + // Read into a scratch page so a failure leaves the live model untouched. + const scratch = new Page({ + index: page.index, + pagePtr: page.pagePtr, + width: page.width, + height: page.height, + display: page.display, + }); + try { + PdfiumTextReader.populate(doc, scratch, mode); + } catch { + return result; + } + if (scratch.runs.length === 0) return result; + + // Index the live runs by every pointer that backs them. + const liveByPtr = new Map(); + for (const run of page.runs) { + for (const ptr of memberPtrsOf(run)) { + if (ptr && !liveByPtr.has(ptr)) liveByPtr.set(ptr, run); + } + } + + // A fresh run belongs to whichever live run it shares the most pointers + // with: grouping can split or merge, so a single shared pointer is not + // enough to claim identity. + const claimed = new Set(); + for (const fresh of scratch.runs) { + const votes = new Map(); + for (const ptr of memberPtrsOf(fresh)) { + const live = liveByPtr.get(ptr); + if (live) votes.set(live, (votes.get(live) ?? 0) + 1); + } + let best: TextRun | null = null; + let bestVotes = 0; + for (const [live, count] of votes) { + if (count > bestVotes && !claimed.has(live)) { + best = live; + bestVotes = count; + } + } + if (!best) { + result.appeared += 1; + continue; + } + claimed.add(best); + result.matched += 1; + if (adoptGeometry(best, fresh)) result.changed = true; + } + result.unmatched = page.runs.length - claimed.size; + return result; + } +} diff --git a/frontend/editor/src/core/tools/pdfTextEditor/pdfium/PdfiumPageRenderer.ts b/frontend/editor/src/core/tools/pdfTextEditor/pdfium/PdfiumPageRenderer.ts new file mode 100644 index 0000000000..46bb5056e3 --- /dev/null +++ b/frontend/editor/src/core/tools/pdfTextEditor/pdfium/PdfiumPageRenderer.ts @@ -0,0 +1,130 @@ +import type { EditorDocument } from "@app/tools/pdfTextEditor/model/EditorDocument"; +import type { Page } from "@app/tools/pdfTextEditor/model/Page"; + +// A display ratio above this buys no visible sharpness for a PDF preview and +// doubles memory per step, so the raster stops following it there. +const MAX_DPR = 3; + +// Budget for one page's bitmap, in pixels (32M ≈ 128MB of RGBA). Zoom and +// device ratio multiply together, and a poster-sized page at that product can +// otherwise ask the wasm heap for gigabytes. +const MAX_RASTER_PIXELS = 32_000_000; + +/** Renders pages to bitmaps for the on-screen preview. */ +export class PdfiumPageRenderer { + static rasterSize( + pageWidth: number, + pageHeight: number, + scale: number, + ): { width: number; height: number } { + return { + width: Math.max(1, Math.round(pageWidth * scale)), + height: Math.max(1, Math.round(pageHeight * scale)), + }; + } + + /** + * The scale to RENDER at for a page displayed at `cssScale`: the display's + * pixel ratio multiplied in, so a HiDPI screen gets real pixels instead of + * a browser-upscaled bitmap, then capped by the per-page pixel budget. + */ + static deviceScale( + pageWidth: number, + pageHeight: number, + cssScale: number, + dpr: number, + ): number { + const ratio = Math.min(Math.max(dpr || 1, 1), MAX_DPR); + const cap = Math.sqrt( + MAX_RASTER_PIXELS / Math.max(1, pageWidth * pageHeight), + ); + return Math.max(0.25, Math.min(cssScale * ratio, cap)); + } + + static async render( + doc: EditorDocument, + page: Page, + scale: number, + ): Promise { + const m = doc.module; + // No flush: FPDF_RenderPageBitmap draws from the in-memory object list, so + // the preview is current without rewriting the content stream. + const { width: w, height: h } = PdfiumPageRenderer.rasterSize( + page.width, + page.height, + scale, + ); + + // BGRA bitmap = format 1, fill white, then render with REVERSE_BYTE_ORDER + // so the pixel buffer is RGBA-ordered for ImageData. + const bitmapPtr = m.FPDFBitmap_Create(w, h, 1); + try { + m.FPDFBitmap_FillRect(bitmapPtr, 0, 0, w, h, 0xffffffff); + // FPDF_REVERSE_BYTE_ORDER = 0x10, FPDF_ANNOT = 0x01 + m.FPDF_RenderPageBitmap( + bitmapPtr, + page.pagePtr, + 0, + 0, + w, + h, + 0, + 0x01 | 0x10, + ); + + // Second pass for the form layer. A widget with no appearance stream is + // drawn ONLY here - FPDF_ANNOT alone leaves such fields blank, which is + // why they were invisible in the editor but fine in the viewer. + const formEnv = doc.formEnvironment(); + if (formEnv) { + doc.notifyFormPageLoaded(page); + const formMod = m as unknown as { + FPDF_FFLDraw?: ( + env: number, + bitmap: number, + pagePtr: number, + startX: number, + startY: number, + sizeX: number, + sizeY: number, + rotate: number, + flags: number, + ) => void; + }; + try { + formMod.FPDF_FFLDraw?.( + formEnv, + bitmapPtr, + page.pagePtr, + 0, + 0, + w, + h, + 0, + 0x01 | 0x10, + ); + } catch { + /* the page content is already drawn; the form layer is additive */ + } + } + + const bufferPtr = m.FPDFBitmap_GetBuffer(bitmapPtr); + const stride = m.FPDFBitmap_GetStride(bitmapPtr); + const pixels = new Uint8ClampedArray(w * h * 4); + const heap = new Uint8Array( + (m.pdfium.wasmExports as unknown as { memory: WebAssembly.Memory }) + .memory.buffer, + bufferPtr, + stride * h, + ); + for (let y = 0; y < h; y++) { + const srcRow = y * stride; + const dstRow = y * w * 4; + pixels.set(heap.subarray(srcRow, srcRow + w * 4), dstRow); + } + return new ImageData(pixels, w, h); + } finally { + m.FPDFBitmap_Destroy(bitmapPtr); + } + } +} diff --git a/frontend/editor/src/core/tools/pdfTextEditor/pdfium/PdfiumSave.ts b/frontend/editor/src/core/tools/pdfTextEditor/pdfium/PdfiumSave.ts new file mode 100644 index 0000000000..4f1c308eef --- /dev/null +++ b/frontend/editor/src/core/tools/pdfTextEditor/pdfium/PdfiumSave.ts @@ -0,0 +1,73 @@ +import type { EditorDocument } from "@app/tools/pdfTextEditor/model/EditorDocument"; + +/** `FPDF_SaveAsCopy` flags. */ +const FPDF_INCREMENTAL = 1; + +interface SaveFlagsModule { + FPDF_SaveAsCopy?: (doc: number, writer: number, flags: number) => boolean; +} + +export interface SerializeOptions { + // Append a revision instead of rewriting: the only way a signature stays + // verifiable for the revision it signed. + incremental?: boolean; +} + +/** Serialise the current edited document back to a `Uint8Array`. */ +export class PdfiumSave { + static serialize( + doc: EditorDocument, + options: SerializeOptions = {}, + ): Uint8Array { + const m = doc.module; + const failedPages: number[] = []; + for (const page of doc.loadedPages()) { + try { + // Always force a flush before save. + if (page.dirty) page.markNeedsGenerate(); + page.flushGenerate(m); + page.clearDirty(); + } catch { + failedPages.push(page.index + 1); + } + } + if (failedPages.length > 0) { + // A swallowed flush failure would serialize the page's stale + // pre-edit content while the UI reports a successful save. + throw new Error( + `Could not apply edits on page${failedPages.length > 1 ? "s" : ""} ` + + `${failedPages.join(", ")}; save aborted so no edits are silently lost.`, + ); + } + + const writerPtr = m.PDFiumExt_OpenFileWriter(); + try { + // The writer the shim hands back is the FPDF_FILEWRITE the flagged + // entry point expects, so incremental mode needs no extra plumbing. + const withFlags = (m as unknown as SaveFlagsModule).FPDF_SaveAsCopy; + if (options.incremental && typeof withFlags === "function") { + withFlags(doc.docPtr, writerPtr, FPDF_INCREMENTAL); + } else { + m.PDFiumExt_SaveAsCopy(doc.docPtr, writerPtr); + } + const size = m.PDFiumExt_GetFileWriterSize(writerPtr); + const outBuf = m.pdfium.wasmExports.malloc(size); + try { + m.PDFiumExt_GetFileWriterData(writerPtr, outBuf, size); + const view = new Uint8Array(size); + const heap = new Uint8Array( + (m.pdfium.wasmExports as unknown as { memory: WebAssembly.Memory }) + .memory.buffer, + outBuf, + size, + ); + view.set(heap); + return view; + } finally { + m.pdfium.wasmExports.free(outBuf); + } + } finally { + m.PDFiumExt_CloseFileWriter(writerPtr); + } + } +} diff --git a/frontend/editor/src/core/tools/pdfTextEditor/pdfium/PdfiumTextReader.ts b/frontend/editor/src/core/tools/pdfTextEditor/pdfium/PdfiumTextReader.ts new file mode 100644 index 0000000000..e3793d90d5 --- /dev/null +++ b/frontend/editor/src/core/tools/pdfTextEditor/pdfium/PdfiumTextReader.ts @@ -0,0 +1,791 @@ +import type { WrappedPdfiumModule } from "@embedpdf/pdfium"; +import { TextRun } from "@app/tools/pdfTextEditor/model/TextRun"; +import { ImageObject } from "@app/tools/pdfTextEditor/model/ImageObject"; +import type { Page } from "@app/tools/pdfTextEditor/model/Page"; +import type { EditorDocument } from "@app/tools/pdfTextEditor/model/EditorDocument"; +import { LineGrouper } from "@app/tools/pdfTextEditor/pdfium/LineGrouper"; +import { ParagraphGrouper } from "@app/tools/pdfTextEditor/pdfium/ParagraphGrouper"; +import { PdfiumAnnotationReader } from "@app/tools/pdfTextEditor/pdfium/PdfiumAnnotationReader"; +import { primeFontGlyphMap } from "@app/tools/pdfTextEditor/charcode/CmapResolver"; +import type { + Affine, + GroupingMode, + PageRect, + RGBA, +} from "@app/tools/pdfTextEditor/types"; +import { readUtf16 } from "@app/services/pdfiumService"; +import { registerEmbeddedFace } from "@app/tools/pdfTextEditor/util/embeddedFace"; + +/** PDFium page-object type constants - mirrors `public/fpdf_edit.h`. */ +const FPDF_PAGEOBJ_TEXT = 1; +const FPDF_PAGEOBJ_IMAGE = 3; +const FPDF_PAGEOBJ_FORM = 5; + +/** Reads the editable objects out of a PDFium page. */ +export class PdfiumTextReader { + static populate( + doc: EditorDocument, + page: Page, + mode: GroupingMode = "auto", + ): void { + if (page.loaded) return; + const m = doc.module; + const pagePtr = page.pagePtr; + const count = m.FPDFPage_CountObjects(pagePtr); + + const runs: TextRun[] = []; + const images: ImageObject[] = []; + + // ONE text page for the whole walk: FPDFText_LoadPage runs full page text + // extraction, so opening it per text object made population O. + const textPagePtr = m.FPDFText_LoadPage(pagePtr); + try { + // Recurse into form xobjects: InDesign/Quark wrap content in + // FPDF_PAGEOBJ_FORM containers and the real text/images only show up. + walkObjects( + m, + pagePtr, + count, + runs, + images, + doc, + page, + [], + 0, + IDENTITY, + textPagePtr, + ); + + page.setRuns(runs); + page.setImages(images); + // Annotation text is drawn by FPDF_ANNOT but lives outside the object + // tree, so record the boxes to explain why it can't be edited. + PdfiumAnnotationReader.populate(m, page); + // LineGrouper always runs (merges per-glyph/per-word source objects into + // one line). + LineGrouper.apply(page); + if (mode === "auto") ParagraphGrouper.apply(page); + // Grouping is done. + // One walk feeds both: each was reading the same characters with its + // own WASM round-trips, doubling the cost of every page read. + const geometry = collectCharGeometry(m, page, textPagePtr); + if (geometry) { + inferRunCharSpacing(page, geometry); + captureCharPositions(geometry); + } + } finally { + m.FPDFText_ClosePage(textPagePtr); + } + page.loaded = true; + } + + // Returns the runs whose captured positions actually moved, so the caller + // can re-snapshot just those instead of re-rendering every overlay per tick. + static recapturePositions(doc: EditorDocument, page: Page): Set { + const m = doc.module; + if (!page.loaded || page.runs.length === 0) return new Set(); + // No flush: like FPDF_RenderPageBitmap, FPDFText_LoadPage walks the live + // object list. Regenerating here cost ~1s per keystroke on Firefox and is + // what save/repopulate do anyway. + const textPagePtr = m.FPDFText_LoadPage(page.pagePtr); + if (!textPagePtr) return new Set(); + try { + const geometry = collectCharGeometry(m, page, textPagePtr); + return geometry ? captureCharPositions(geometry) : new Set(); + } finally { + m.FPDFText_ClosePage(textPagePtr); + } + } +} + +/** Every backing PDFium object pointer mapped to its post-grouping run. */ +function indexRunsByObjectPtr(runs: TextRun[]): Map { + const map = new Map(); + for (const run of runs) { + const members = + run.paragraphLeafPtrs.length > 0 + ? run.paragraphLeafPtrs + : run.mergedFromPtrs.length > 0 + ? run.mergedFromPtrs + : [run.pdfiumObjPtr]; + for (const ptr of members) if (ptr) map.set(ptr, run); + } + return map; +} + +// Infer each run's effective character spacing from on-page char geometry: for +// consecutive text-page chars inside one run, `extra = nextOrigin.x - origin.x. +interface CharGeometry { + cp: number; + run: TextRun | null; + /** False when the engine could not give this character a box. */ + ok: boolean; + left: number; + right: number; + bottom: number; + originX: number; +} + +// Read every character's geometry once. Both consumers below need the same +// characters, so doing this twice was pure duplicated WASM traffic. +function collectCharGeometry( + m: WrappedPdfiumModule, + page: Page, + textPagePtr: number, +): CharGeometry[] | null { + if (page.runs.length === 0) return null; + const probe = m as unknown as { + FPDFText_GetLooseCharBox?: (tp: number, i: number, rect: number) => boolean; + FPDFText_GetCharOrigin?: ( + tp: number, + i: number, + x: number, + y: number, + ) => boolean; + }; + if (!probe.FPDFText_GetLooseCharBox) return null; + const charCount = m.FPDFText_CountChars(textPagePtr); + if (charCount <= 1) return null; + + const ptrToRun = indexRunsByObjectPtr(page.runs); + const wasm = m.pdfium.wasmExports; + const rectBuf = wasm.malloc(16); // FS_RECT: 4 floats {l, t, r, b} + const xPtr = wasm.malloc(8); + const yPtr = wasm.malloc(8); + const out: CharGeometry[] = []; + try { + for (let i = 0; i < charCount; i += 1) { + const cp = m.FPDFText_GetUnicode(textPagePtr, i); + const objPtr = m.FPDFText_GetTextObject(textPagePtr, i); + const run = objPtr ? (ptrToRun.get(objPtr) ?? null) : null; + const boxed = probe.FPDFText_GetLooseCharBox(textPagePtr, i, rectBuf); + const heap = (m.pdfium as unknown as { HEAPU8: Uint8Array }).HEAPU8; + const f = new Float32Array(heap.buffer, rectBuf, 4); + let originX = Number.NaN; + if (probe.FPDFText_GetCharOrigin?.(textPagePtr, i, xPtr, yPtr)) { + originX = m.pdfium.getValue(xPtr, "double"); + } + out.push({ + cp, + run, + ok: boxed, + left: boxed ? f[0] : Number.NaN, + right: boxed ? f[2] : Number.NaN, + bottom: boxed ? f[3] : Number.NaN, + originX, + }); + } + } finally { + wasm.free(rectBuf); + wasm.free(xPtr); + wasm.free(yPtr); + } + return out; +} + +function inferRunCharSpacing(page: Page, geometry: CharGeometry[]): void { + if (page.runs.length === 0) return; + const samples = new Map(); + let prev: { + run: TextRun; + left: number; + right: number; + bottom: number; + } | null = null; + for (const g of geometry) { + const isWs = !g.cp || g.cp <= 0x20 || g.cp === 0xa0; + if (isWs) { + // A REAL space glyph (belongs to a text object) ends the pair chain - + // pairs across it would fold word spacing (Tw) into the estimate. + if (g.run) prev = null; + continue; + } + if (!g.run || !g.ok) { + prev = null; + continue; + } + const run = g.run; + const cur = { run, left: g.left, right: g.right, bottom: g.bottom }; + if (prev && prev.run === run) { + const advance = prev.right - prev.left; + const delta = cur.left - prev.left; + const extra = delta - advance; + // Same visual line, forward advance only, and NOT a word gap: real + // letter-spacing stays well under ~0.6em. + if ( + delta > 0 && + advance > 0 && + extra < run.fontSize * 0.6 && + Math.abs(cur.bottom - prev.bottom) < Math.max(1, run.fontSize * 0.25) + ) { + let arr = samples.get(run); + if (!arr) { + arr = []; + samples.set(run, arr); + } + arr.push(extra); + } + } + prev = cur; + } + + for (const [run, extras] of samples) { + if (extras.length < 2) continue; + // Upright runs only - the box math above is axis-aligned. + const scale = Math.hypot(run.matrix.a, run.matrix.b); + if (!scale || Math.abs(run.matrix.b) / scale > 0.02 || run.matrix.a <= 0) { + continue; + } + const sorted = [...extras].sort((a, b) => a - b); + const median = sorted[Math.floor(sorted.length / 2)]; + // Noise floor: kerning tweaks and float fuzz stay well under 2% of the + // font size; a real Tc (like a spaced-caps heading) is far above it. + const noise = Math.max(0.25, run.fontSize * 0.02); + if (Math.abs(median) < noise) continue; + // Sanity cap - a broken measurement must not explode the layout. + if (Math.abs(median) > run.fontSize * 2) continue; + run.charSpacingPt = median; + } +} + +/** NaN-safe element-wise equality for captured position arrays. */ +function samePositions(prev: number[] | null, next: number[]): boolean { + if (!prev || prev.length !== next.length) return false; + for (let i = 0; i < prev.length; i += 1) { + if (!Object.is(prev[i], next[i])) return false; + } + return true; +} + +// Record where the engine put every glyph, indexed by code unit of `text`. +// Both units of a surrogate pair share a value; synthesised spaces stay NaN. +function captureCharPositions(geometry: CharGeometry[]): Set { + const glyphs = new Map< + TextRun, + Array<{ cp: number; x: number; end: number }> + >(); + for (const g of geometry) { + if (!g.run || !g.cp || !g.ok) continue; + if (!Number.isFinite(g.originX) || g.right < g.originX) continue; + let list = glyphs.get(g.run); + if (!list) { + list = []; + glyphs.set(g.run, list); + } + // The loose box's right edge is the pen position after the glyph, which + // is what makes consecutive word boxes tile without drift. + list.push({ cp: g.cp, x: g.originX, end: g.right }); + } + + const changed = new Set(); + for (const [run, list] of glyphs) { + // Upright runs only: an origin's X is the advance direction only when the + // baseline is horizontal. + const scale = Math.hypot(run.matrix.a, run.matrix.b); + if (!scale || Math.abs(run.matrix.b) / scale > 0.02 || run.matrix.a <= 0) { + continue; + } + const aligned = alignToText(run.text, list); + if (!aligned) continue; + // Same positions under a still-current key is a no-op capture; skipping it + // keeps untouched runs' snapshots stable across the periodic tick. + if ( + samePositions(run.charStartsX, aligned.starts) && + samePositions(run.charEndsX, aligned.ends) && + run.charPositionsKey === run.positionsKey() + ) { + continue; + } + run.charStartsX = aligned.starts; + run.charEndsX = aligned.ends; + run.charPositionsKey = run.positionsKey(); + changed.add(run); + } + return changed; +} + +// Line up the engine's glyph list with the run's text - they are not +// index-for-index, and anything unplaceable is left unknown, not guessed. +function alignToText( + text: string, + glyphs: Array<{ cp: number; x: number; end: number }>, +): { starts: number[]; ends: number[] } | null { + const starts = new Array(text.length).fill(Number.NaN); + const ends = new Array(text.length).fill(Number.NaN); + let g = 0; + let placed = 0; + for (let i = 0; i < text.length;) { + const cp = text.codePointAt(i) ?? 0; + const units = cp > 0xffff ? 2 : 1; + if (g < glyphs.length && glyphs[g].cp === cp) { + for (let u = 0; u < units; u += 1) { + starts[i + u] = glyphs[g].x; + ends[i + u] = glyphs[g].end; + } + g += 1; + placed += 1; + } else if (g < glyphs.length && cp !== 0x20 && cp !== 0x0a) { + // The text has a character the glyph list does not: look at the next + // couple of glyphs only, so a long mismatching run stays linear. + let next = -1; + for (let at = g + 1; at <= g + 2 && at < glyphs.length; at += 1) { + if (glyphs[at].cp === cp) { + next = at; + break; + } + } + if (next > 0) { + g = next; + continue; + } + } + i += units; + } + // A capture that placed almost nothing is not worth trusting. + const visible = [...text].filter((c) => !/\s/.test(c)).length; + return placed >= Math.max(1, Math.floor(visible * 0.6)) + ? { starts, ends } + : null; +} + +/** Walk a list of PDFium page objects, collecting text and image objects. */ +type PdfiumWithForms = WrappedPdfiumModule & { + FPDFFormObj_CountObjects: (formObj: number) => number; + FPDFFormObj_GetObject: (formObj: number, index: number) => number; +}; + +function walkObjects( + m: WrappedPdfiumModule, + pagePtr: number, + count: number, + runs: TextRun[], + images: ImageObject[], + doc: EditorDocument, + page: Page, + path: number[], + depth: number, + transform: Affine, + textPagePtr: number, +): void { + const MAX_DEPTH = 4; + const formModule = m as PdfiumWithForms; + // Container pointer for the current depth - either the page (path=[]) + // or the form xobject we're recursing into. + const containerPtr = + path.length === 0 ? 0 : getFormContainer(m, pagePtr, path); + const topLevelContainerPtr = + path.length === 0 ? 0 : m.FPDFPage_GetObject(pagePtr, path[0]); + for (let i = 0; i < count; i++) { + const objPtr = + path.length === 0 + ? m.FPDFPage_GetObject(pagePtr, i) + : formModule.FPDFFormObj_GetObject(containerPtr, i); + if (!objPtr) continue; + const type = m.FPDFPageObj_GetType(objPtr); + if (type === FPDF_PAGEOBJ_TEXT) { + const indexId = [...path, i].join("-"); + const run = readTextRun( + m, + doc, + page, + objPtr, + indexId, + transform, + textPagePtr, + ); + if (run) { + run.containerPtr = containerPtr; + run.topLevelContainerPtr = topLevelContainerPtr; + runs.push(run); + } + } else if (type === FPDF_PAGEOBJ_IMAGE) { + const indexId = [...path, i].join("-"); + const img = readImage(m, page, objPtr, indexId, transform, containerPtr); + if (img) images.push(img); + } else if (type === FPDF_PAGEOBJ_FORM && depth < MAX_DEPTH) { + let formCount: number; + try { + formCount = formModule.FPDFFormObj_CountObjects(objPtr); + } catch { + formCount = 0; + } + if (formCount > 0) { + // Compose the form's own matrix onto the running transform so + // children's form-local coordinates resolve to page space. + const childTransform = composeAffine(transform, readMatrix(m, objPtr)); + walkObjects( + m, + pagePtr, + formCount, + runs, + images, + doc, + page, + [...path, i], + depth + 1, + childTransform, + textPagePtr, + ); + } + } + } +} + +/** Identity affine - the page-level transform. */ +const IDENTITY: Affine = { a: 1, b: 0, c: 0, d: 1, e: 0, f: 0 }; + +// Compose two affines: returns `parent ∘ child` (child applied first, then +// parent). +function composeAffine(parent: Affine, child: Affine): Affine { + return { + a: parent.a * child.a + parent.c * child.b, + b: parent.b * child.a + parent.d * child.b, + c: parent.a * child.c + parent.c * child.d, + d: parent.b * child.c + parent.d * child.d, + e: parent.a * child.e + parent.c * child.f + parent.e, + f: parent.b * child.e + parent.d * child.f + parent.f, + }; +} + +/** Map a point through an affine. */ +function applyAffine( + t: Affine, + x: number, + y: number, +): { x: number; y: number } { + return { x: t.a * x + t.c * y + t.e, y: t.b * x + t.d * y + t.f }; +} + +// Transform an axis-aligned rect by an affine and return the new AABB (all four +// corners mapped, then min/max). +function transformRect(t: Affine, r: PageRect): PageRect { + const c0 = applyAffine(t, r.x, r.y); + const c1 = applyAffine(t, r.x + r.width, r.y); + const c2 = applyAffine(t, r.x, r.y + r.height); + const c3 = applyAffine(t, r.x + r.width, r.y + r.height); + const xs = [c0.x, c1.x, c2.x, c3.x]; + const ys = [c0.y, c1.y, c2.y, c3.y]; + const minX = Math.min(...xs); + const minY = Math.min(...ys); + return { + x: minX, + y: minY, + width: Math.max(...xs) - minX, + height: Math.max(...ys) - minY, + }; +} + +/** True when the affine is (close to) the identity - skip work if so. */ +function isIdentity(t: Affine): boolean { + return ( + t.a === 1 && t.b === 0 && t.c === 0 && t.d === 1 && t.e === 0 && t.f === 0 + ); +} + +// Re-walk to the form container at the given index path so the recursive call +// can index its children. +function getFormContainer( + m: WrappedPdfiumModule, + pagePtr: number, + path: number[], +): number { + const formModule = m as PdfiumWithForms; + let current = m.FPDFPage_GetObject(pagePtr, path[0]); + for (let i = 1; i < path.length; i++) { + current = formModule.FPDFFormObj_GetObject(current, path[i]); + } + return current; +} + +function readBounds(m: WrappedPdfiumModule, objPtr: number): PageRect | null { + const lPtr = m.pdfium.wasmExports.malloc(4); + const bPtr = m.pdfium.wasmExports.malloc(4); + const rPtr = m.pdfium.wasmExports.malloc(4); + const tPtr = m.pdfium.wasmExports.malloc(4); + try { + if (!m.FPDFPageObj_GetBounds(objPtr, lPtr, bPtr, rPtr, tPtr)) return null; + const left = m.pdfium.getValue(lPtr, "float"); + const bottom = m.pdfium.getValue(bPtr, "float"); + const right = m.pdfium.getValue(rPtr, "float"); + const top = m.pdfium.getValue(tPtr, "float"); + return { + x: Math.min(left, right), + y: Math.min(bottom, top), + width: Math.abs(right - left), + height: Math.abs(top - bottom), + }; + } finally { + m.pdfium.wasmExports.free(lPtr); + m.pdfium.wasmExports.free(bPtr); + m.pdfium.wasmExports.free(rPtr); + m.pdfium.wasmExports.free(tPtr); + } +} + +function readMatrix(m: WrappedPdfiumModule, objPtr: number): Affine { + // FS_MATRIX: { a, b, c, d, e, f } as floats. + const buf = m.pdfium.wasmExports.malloc(6 * 4); + try { + const ok = m.FPDFPageObj_GetMatrix(objPtr, buf); + if (!ok) return { a: 1, b: 0, c: 0, d: 1, e: 0, f: 0 }; + return { + a: m.pdfium.getValue(buf, "float"), + b: m.pdfium.getValue(buf + 4, "float"), + c: m.pdfium.getValue(buf + 8, "float"), + d: m.pdfium.getValue(buf + 12, "float"), + e: m.pdfium.getValue(buf + 16, "float"), + f: m.pdfium.getValue(buf + 20, "float"), + }; + } finally { + m.pdfium.wasmExports.free(buf); + } +} + +function readFill(m: WrappedPdfiumModule, objPtr: number): RGBA { + const r = m.pdfium.wasmExports.malloc(4); + const g = m.pdfium.wasmExports.malloc(4); + const b = m.pdfium.wasmExports.malloc(4); + const a = m.pdfium.wasmExports.malloc(4); + try { + const ok = m.FPDFPageObj_GetFillColor(objPtr, r, g, b, a); + if (!ok) return { r: 0, g: 0, b: 0, a: 255 }; + return { + r: m.pdfium.getValue(r, "i32") & 0xff, + g: m.pdfium.getValue(g, "i32") & 0xff, + b: m.pdfium.getValue(b, "i32") & 0xff, + a: m.pdfium.getValue(a, "i32") & 0xff, + }; + } finally { + m.pdfium.wasmExports.free(r); + m.pdfium.wasmExports.free(g); + m.pdfium.wasmExports.free(b); + m.pdfium.wasmExports.free(a); + } +} + +interface StrokeReaderModule { + FPDFPageObj_GetStrokeColor?: ( + obj: number, + r: number, + g: number, + b: number, + a: number, + ) => boolean; + FPDFPageObj_GetStrokeWidth?: (obj: number, out: number) => boolean; +} + +/** Render modes that actually put stroke ink on the page. */ +const STROKING_MODES = new Set([1, 2, 5, 6]); + +// Outline colour and width, or null when the object does not stroke. PDFium +// reports a stroke colour for every text object, so the render mode decides. +function readStroke( + m: WrappedPdfiumModule, + objPtr: number, + renderMode: number, +): { stroke: RGBA | null; strokeWidth: number } { + if (!STROKING_MODES.has(renderMode)) return { stroke: null, strokeWidth: 0 }; + const mod = m as unknown as StrokeReaderModule; + const getColor = mod.FPDFPageObj_GetStrokeColor; + const getWidth = mod.FPDFPageObj_GetStrokeWidth; + if (!getColor) return { stroke: null, strokeWidth: 0 }; + const r = m.pdfium.wasmExports.malloc(4); + const g = m.pdfium.wasmExports.malloc(4); + const b = m.pdfium.wasmExports.malloc(4); + const a = m.pdfium.wasmExports.malloc(4); + const w = m.pdfium.wasmExports.malloc(4); + try { + if (!getColor(objPtr, r, g, b, a)) return { stroke: null, strokeWidth: 0 }; + const alpha = m.pdfium.getValue(a, "i32") & 0xff; + let strokeWidth = 0; + if (getWidth && getWidth(objPtr, w)) { + const raw = m.pdfium.getValue(w, "float"); + if (Number.isFinite(raw) && raw > 0) strokeWidth = raw; + } + return { + stroke: { + r: m.pdfium.getValue(r, "i32") & 0xff, + g: m.pdfium.getValue(g, "i32") & 0xff, + b: m.pdfium.getValue(b, "i32") & 0xff, + a: alpha, + }, + strokeWidth, + }; + } catch { + return { stroke: null, strokeWidth: 0 }; + } finally { + m.pdfium.wasmExports.free(r); + m.pdfium.wasmExports.free(g); + m.pdfium.wasmExports.free(b); + m.pdfium.wasmExports.free(a); + m.pdfium.wasmExports.free(w); + } +} + +function readTextObjString( + m: WrappedPdfiumModule, + textPagePtr: number, + objPtr: number, +): string { + // First call returns size in bytes for the UTF-16 buffer (including NUL). + const len = m.FPDFTextObj_GetText(objPtr, textPagePtr, 0, 0); + if (len <= 2) return ""; + const buf = m.pdfium.wasmExports.malloc(len); + try { + m.FPDFTextObj_GetText(objPtr, textPagePtr, buf, len); + return readUtf16(m, buf, len); + } finally { + m.pdfium.wasmExports.free(buf); + } +} + +/** 6-letter "ABCDEF+" subset tag PDFium prefixes onto subset font names. */ +const SUBSET_TAG_RE = /^[A-Z]{6}\+/; + +/** Read a UTF-8 font name via an FPDFFont_Get*Name accessor (null if empty). */ +function readFontNameVia( + m: WrappedPdfiumModule, + fontPtr: number, + getName: (font: number, buf: number, len: number) => number, +): string | null { + const len = getName(fontPtr, 0, 0); + if (len <= 1) return null; + const buf = m.pdfium.wasmExports.malloc(len); + try { + getName(fontPtr, buf, len); + return m.pdfium.UTF8ToString(buf); + } finally { + m.pdfium.wasmExports.free(buf); + } +} + +function readFontFamily( + m: WrappedPdfiumModule, + fontPtr: number, +): { family: string; subset: boolean } { + if (!fontPtr) return { family: "Unknown", subset: false }; + const familyRaw = readFontNameVia(m, fontPtr, m.FPDFFont_GetFamilyName); + // Some PDFs carry the 6-letter subset tag only on /BaseFont, not the embedded + // name table. + const baseRaw = readFontNameVia(m, fontPtr, m.FPDFFont_GetBaseFontName); + // Plenty of embedded fonts expose no name-table family at all. /BaseFont + // still names the face, and that name is what decides the fallback's + // serif/sans class - calling it "Unknown" silently substituted Helvetica + // into serif documents. + const nameRaw = familyRaw ?? baseRaw; + if (nameRaw == null) return { family: "Unknown", subset: false }; + const tagged = SUBSET_TAG_RE.test(nameRaw); + const family = tagged ? nameRaw.slice(7) : nameRaw; + if (tagged) return { family, subset: true }; + return { family, subset: baseRaw != null && SUBSET_TAG_RE.test(baseRaw) }; +} + +function readTextRun( + m: WrappedPdfiumModule, + _doc: EditorDocument, + page: Page, + objPtr: number, + index: number | string, + transform: Affine, + textPagePtr: number, +): TextRun | null { + { + const text = readTextObjString(m, textPagePtr, objPtr); + if (!text || text.length === 0) return null; + // Whitespace-only objects (positional space glyphs) would surface as + // invisible, selectable, editable ghost runs - skip them. + if (text.trim().length === 0) return null; + + const localBounds = readBounds(m, objPtr); + if (!localBounds) return null; + const localMatrix = readMatrix(m, objPtr); + const fill = readFill(m, objPtr); + + // Lift form-local coordinates into page space. For page-level text + // `transform` is identity and these are no-ops. + const ident = isIdentity(transform); + const bounds = ident ? localBounds : transformRect(transform, localBounds); + const matrix = ident ? localMatrix : composeAffine(transform, localMatrix); + + const sizePtr = m.pdfium.wasmExports.malloc(4); + let rawFontSize = 12; + try { + if (m.FPDFTextObj_GetFontSize(objPtr, sizePtr)) { + rawFontSize = m.pdfium.getValue(sizePtr, "float"); + } + } finally { + m.pdfium.wasmExports.free(sizePtr); + } + // The on-page visible font size is `rawFontSize * |matrix scale|`. + const matrixScale = + Math.sqrt(matrix.a * matrix.a + matrix.b * matrix.b) || 1; + const fontSize = rawFontSize * matrixScale; + + const fontPtr = m.FPDFTextObj_GetFont(objPtr); + const { family, subset } = readFontFamily(m, fontPtr); + // Prime this font's glyph cmap here, in the loader's SERIALIZED text-read + // phase (before the page rasterizes). + if (fontPtr) primeFontGlyphMap(fontPtr, m); + // Make the same face available to the overlay as a CSS FontFace. + if (fontPtr) registerEmbeddedFace(m, fontPtr); + // Treat the PDFium font handle pointer as a unique id within the doc. + const fontId = fontPtr ? `pdf:${fontPtr}` : `pdf:unknown-${index}`; + + // Text render mode (PDF Tr): 0 fill (default), 1/2 stroke variants, 3 + // invisible (OCR text layers over scans), 4-7 clipping variants. + let renderMode = 0; + const rm = ( + m as unknown as { + FPDFTextObj_GetTextRenderMode?: (obj: number) => number; + } + ).FPDFTextObj_GetTextRenderMode; + if (rm) { + try { + const v = rm(objPtr); + if (Number.isInteger(v) && v >= 0 && v <= 7) renderMode = v; + } catch { + /* keep default */ + } + } + + const { stroke, strokeWidth } = readStroke(m, objPtr, renderMode); + + return new TextRun({ + id: `p${page.index}-t${index}`, + pageIndex: page.index, + pdfiumObjPtr: objPtr, + bounds, + matrix, + text, + fontId: `${fontId}:${family}`, + fontSize, + fill, + fontSubset: subset, + renderMode, + stroke: stroke ?? undefined, + strokeWidth, + }); + } +} + +function readImage( + m: WrappedPdfiumModule, + page: Page, + objPtr: number, + index: number | string, + transform: Affine, + containerPtr: number, +): ImageObject | null { + const localBounds = readBounds(m, objPtr); + if (!localBounds) return null; + const localMatrix = readMatrix(m, objPtr); + const ident = isIdentity(transform); + return new ImageObject({ + id: `p${page.index}-i${index}`, + pageIndex: page.index, + pdfiumObjPtr: objPtr, + bounds: ident ? localBounds : transformRect(transform, localBounds), + matrix: ident ? localMatrix : composeAffine(transform, localMatrix), + containerPtr, + }); +} diff --git a/frontend/editor/src/core/tools/pdfTextEditor/pdfium/PdfiumTextWriter.ts b/frontend/editor/src/core/tools/pdfTextEditor/pdfium/PdfiumTextWriter.ts new file mode 100644 index 0000000000..ed11bd073b --- /dev/null +++ b/frontend/editor/src/core/tools/pdfTextEditor/pdfium/PdfiumTextWriter.ts @@ -0,0 +1,101 @@ +import type { EditorDocument } from "@app/tools/pdfTextEditor/model/EditorDocument"; +import type { Page } from "@app/tools/pdfTextEditor/model/Page"; +import type { TextRun } from "@app/tools/pdfTextEditor/model/TextRun"; +import { writeUtf16 } from "@app/services/pdfiumService"; +import { collectMemberPtrs } from "@app/tools/pdfTextEditor/commands/editTextHelpers"; +import type { WrappedPdfiumModule } from "@embedpdf/pdfium"; + +// Narrowest base-14 glyph ("i") is ~0.22em, so ink well under ~0.15em per +// visible char means the font produced .notdef / zero-width filler. +const MIN_INK_EM_PER_CHAR = 0.15; + +/** Pushes `TextRun` mutations into PDFium. */ +export class PdfiumTextWriter { + /** + * Set the run's text on its existing PDFium object. + * + * Returns false when the object's font could not actually encode the text. + * `FPDFText_SetText` re-encodes from Unicode and silently substitutes filler + * charcodes for anything the font cannot map - on a Type 3 or symbolically + * encoded subset that yields blank, zero-advance glyphs. The caller must + * treat false as "this fast path is unusable" and re-emit through the + * validated overlay path instead of shipping the corrupted object. + */ + static commitRunText(doc: EditorDocument, page: Page, run: TextRun): boolean { + if (!run.pdfiumObjPtr) return false; + const m = doc.module; + const ptr = writeUtf16(m, run.text); + try { + m.FPDFText_SetText(run.pdfiumObjPtr, ptr); + } finally { + m.pdfium.wasmExports.free(ptr); + } + // Defer the regen: FPDFPageObj_GetBounds reads the object, not the + // stream, and a direct call here would skip the page's regenerated flag. + page.markNeedsGenerate(); + // Re-measure the run's bounds. Stale width corrupts all of those. + const bbox = measureObjBboxPt(m, run.pdfiumObjPtr); + if (!bbox) { + // Can't measure, so can't disprove the write; keep the old behaviour. + return true; + } + const width = Math.max(0, bbox.right - bbox.left); + const visible = run.text.replace(/\s+/gu, "").length; + const fontSize = run.fontSize > 0 ? run.fontSize : 0; + if (visible > 0 && fontSize > 0) { + if (width < visible * fontSize * MIN_INK_EM_PER_CHAR) { + // Leave `run.bounds` alone: the collapsed box is not real geometry. + return false; + } + } + run.bounds = { ...run.bounds, x: bbox.left, width }; + return true; + } + + static commitRunFill(doc: EditorDocument, page: Page, run: TextRun): void { + const m = doc.module; + // Recolour EVERY sub-object. + const ptrs = collectMemberPtrs(run); + if (ptrs.every((p) => !p)) return; + const seen = new Set(); + for (const ptr of ptrs) { + if (!ptr || seen.has(ptr)) continue; + seen.add(ptr); + try { + m.FPDFPageObj_SetFillColor( + ptr, + run.fill.r, + run.fill.g, + run.fill.b, + run.fill.a, + ); + } catch { + /* best-effort - stale ptrs silently skipped */ + } + } + page.markNeedsGenerate(); + } +} + +/** Read the visible-bbox of a text object in PDF points. */ +function measureObjBboxPt( + m: WrappedPdfiumModule, + objPtr: number, +): { left: number; right: number } | null { + const l = m.pdfium.wasmExports.malloc(4); + const b = m.pdfium.wasmExports.malloc(4); + const r = m.pdfium.wasmExports.malloc(4); + const t = m.pdfium.wasmExports.malloc(4); + try { + if (!m.FPDFPageObj_GetBounds(objPtr, l, b, r, t)) return null; + return { + left: m.pdfium.getValue(l, "float"), + right: m.pdfium.getValue(r, "float"), + }; + } finally { + m.pdfium.wasmExports.free(l); + m.pdfium.wasmExports.free(b); + m.pdfium.wasmExports.free(r); + m.pdfium.wasmExports.free(t); + } +} diff --git a/frontend/editor/src/core/tools/pdfTextEditor/store/EditorStore.ts b/frontend/editor/src/core/tools/pdfTextEditor/store/EditorStore.ts new file mode 100644 index 0000000000..05edd8a1e8 --- /dev/null +++ b/frontend/editor/src/core/tools/pdfTextEditor/store/EditorStore.ts @@ -0,0 +1,514 @@ +import { EditorDocument } from "@app/tools/pdfTextEditor/model/EditorDocument"; +import { HistoryStack } from "@app/tools/pdfTextEditor/store/HistoryStack"; +import { Selection } from "@app/tools/pdfTextEditor/store/Selection"; +import { pageGuides } from "@app/tools/pdfTextEditor/util/guides"; +import { PdfiumTextReader } from "@app/tools/pdfTextEditor/pdfium/PdfiumTextReader"; +import { + PdfiumModelSync, + type ModelSyncResult, +} from "@app/tools/pdfTextEditor/pdfium/PdfiumModelSync"; +import { resetBackendResolverCaches } from "@app/tools/pdfTextEditor/charcode/BackendResolver"; +import { resetCmapCache } from "@app/tools/pdfTextEditor/charcode/CmapResolver"; +import { resetContentStreamCache } from "@app/tools/pdfTextEditor/charcode/ContentStreamResolver"; +import { + resetCharCoverageCache, + resetDroppedBase14Chars, + resetOnPageAdvCache, + resetPerCharBranchPtrs, +} from "@app/tools/pdfTextEditor/commands/editTextHelpers"; +import type { Command } from "@app/tools/pdfTextEditor/commands/Command"; +import type { TextRun } from "@app/tools/pdfTextEditor/model/TextRun"; +import type { + GroupingMode, + PageSnapshot, + WidthMode, +} from "@app/tools/pdfTextEditor/types"; +import { resetEmbeddedFaces } from "@app/tools/pdfTextEditor/util/embeddedFace"; + +/** Drop EVERY per-document charcode/glyph cache. */ +function resetCharcodeCaches(): void { + resetBackendResolverCaches(); + resetCmapCache(); + resetContentStreamCache(); + resetOnPageAdvCache(); + // The per-char ptr set is doc-scoped since PDFium reuses pointers. + resetPerCharBranchPtrs(); + // The dropped-char record is per-session/per-document, not pointer-keyed. + resetDroppedBase14Chars(); + resetCharCoverageCache(); + // FontFaces are keyed by font pointer, which PDFium reuses across documents. + resetEmbeddedFaces(); +} + +export type InteractionMode = "select" | "addText"; + +export interface LoadProgress { + /** Stage description shown in the loader: "Reading file", "Parsing PDF", "Loading page 3/60", etc. */ + stage: string; + /** Completed work units (e.g. pages loaded). */ + current: number; + /** Total work units (e.g. total pages). 0 when unknown. */ + total: number; +} + +export interface EditorViewState { + hasDocument: boolean; + pageCount: number; + pages: PageSnapshot[]; + /** Document-level dirty bit (any page dirty). */ + dirty: boolean; + /** Async lifecycle markers. */ + loading: boolean; + /** True once the first page's bitmap has actually painted in PageView. */ + firstPageRendered: boolean; + /** Detailed progress for the loading state. */ + progress: LoadProgress | null; + error: string | null; + // Set when a load hit a password-protected PDF and the UI should prompt. + // `retry` is true after a wrong password so the prompt can say so. + passwordPrompt: { fileName: string; retry: boolean } | null; + /** Pixel scale at which previews are rendered. */ + renderScale: number; + /** What clicks on the page area do. */ + mode: InteractionMode; + /** How the reader clusters source text into editable runs. */ + groupingMode: GroupingMode; + // How an editable text box resizes as the user types more than fits: - + // "grow": the box widens to the right, never wrapping. + widthMode: WidthMode; + /** Show per-page rulers and alignment guides. */ + showRulers: boolean; +} + +const POSITION_REFRESH_MS = 600; + +// Longest the engine's pen positions may stay stale while the user keeps +// typing. Past this the debounce above stops being postponed and runs anyway. +const POSITION_REFRESH_MAX_STALL_MS = 100; + +const INITIAL: EditorViewState = { + hasDocument: false, + pageCount: 0, + pages: [], + dirty: false, + loading: false, + firstPageRendered: false, + progress: null, + error: null, + passwordPrompt: null, + renderScale: 1.5, + mode: "select", + groupingMode: "auto", + widthMode: "grow", + showRulers: false, +}; + +// Single observable store for the editor's React layer. Components never reach +// into PDFium directly - they dispatch commands. +export class EditorStore { + readonly history: HistoryStack; + readonly selection: Selection; + private doc: EditorDocument | null; + private state: EditorViewState; + private listeners: Set<(s: EditorViewState) => void>; + // The undo-stack TOP at the last save; the doc is dirty when the current top + // is a different command object. + private savedTop: Command | null = null; + /** True when edits were baked into the stream (e.g. grouping-mode switch). */ + private bakedDirty = false; + private positionRefreshTimer: number | null = null; + /** When the debounced position refresh last actually ran. */ + private lastPositionRefreshAt = 0; + /** Monotonic token so a superseded async load can detect it lost the race. */ + private loadToken = 0; + /** File awaiting a password retry; held off the view state (not serialisable). */ + private _pendingPasswordFile: File | null = null; + + constructor() { + this.history = new HistoryStack(); + this.selection = new Selection(); + this.doc = null; + this.state = INITIAL; + this.listeners = new Set(); + } + + get document(): EditorDocument | null { + return this.doc; + } + + getState(): EditorViewState { + return this.state; + } + + subscribe(listener: (s: EditorViewState) => void): () => void { + this.listeners.add(listener); + return () => this.listeners.delete(listener); + } + + setLoading(loading: boolean): void { + // Starting a load clears any stale error. + if (loading) { + this.patch({ loading: true, error: null }); + } else { + this.patch({ loading: false, progress: null }); + } + } + + setProgress(progress: LoadProgress | null): void { + this.patch({ progress }); + } + + markFirstPageRendered(): void { + if (this.state.firstPageRendered) return; + this.patch({ firstPageRendered: true }); + } + + setError(error: string | null): void { + this.patch({ error, loading: false }); + } + + /** A load needs a password. */ + setPasswordRequired(file: File, retry: boolean): void { + this._pendingPasswordFile = file; + this.patch({ + passwordPrompt: { fileName: file.name, retry }, + loading: false, + error: null, + }); + } + + /** Dismiss the password prompt (cancel or success) and drop the pending file. */ + clearPasswordPrompt(): void { + this._pendingPasswordFile = null; + if (this.state.passwordPrompt) this.patch({ passwordPrompt: null }); + } + + get pendingPasswordFile(): File | null { + return this._pendingPasswordFile; + } + + setRenderScale(scale: number): void { + this.patch({ renderScale: scale }); + } + + setMode(mode: InteractionMode): void { + this.patch({ mode }); + } + + setWidthMode(widthMode: WidthMode): void { + this.patch({ widthMode }); + } + + setShowRulers(showRulers: boolean): void { + this.patch({ showRulers }); + } + + get groupingMode(): GroupingMode { + return this.state.groupingMode; + } + + // Switch how source text is clustered into runs (Auto = detect paragraphs, + // Line = one run per source line). + setGroupingMode(mode: GroupingMode): void { + if (this.state.groupingMode === mode) return; + const doc = this.doc; + if (!doc) { + this.patch({ groupingMode: mode }); + return; + } + // Re-reading rebuilds run IDs, so the undo history can't survive the switch + // and is cleared. + const wasDirty = this.isDirty(); + // Flushes first: the rebuilt runs must reflect the user's current edits. + this.repopulateAllPages(doc, mode); + this.history.clear(); + this.savedTop = null; + this.bakedDirty = wasDirty; + this.selection.clear(); + const pages: PageSnapshot[] = this.state.pages.map((p) => { + const live = doc.page(p.pageIndex); + if (!live.loaded) return p; + return { + ...p, + revision: live.revision, + runs: live.runs.map((r) => r.snapshot()), + images: live.images.map((img) => img.snapshot()), + // Regrouping re-populates the page, which re-reads its annotations. + annotations: live.annotations, + }; + }); + this.patch({ groupingMode: mode, pages, dirty: this.isDirty() }); + } + + /** Begin a load and return a token. */ + beginLoad(): number { + return ++this.loadToken; + } + + isCurrentLoad(token: number): boolean { + return this.loadToken === token; + } + + async setDocument(doc: EditorDocument): Promise { + this.disposeDocumentIfAny(); + resetCharcodeCaches(); + this.doc = doc; + this.history.clear(); + this.savedTop = null; + this.bakedDirty = false; + this.selection.clear(); + pageGuides.clear(); + this._pendingPasswordFile = null; + this.patch({ + hasDocument: true, + pageCount: doc.pageCount, + pages: [], + dirty: false, + loading: false, + firstPageRendered: false, + error: null, + passwordPrompt: null, + }); + } + + clearDocument(): void { + this.disposeDocumentIfAny(); + resetCharcodeCaches(); + this.history.clear(); + this.savedTop = null; + this.bakedDirty = false; + this.selection.clear(); + this._pendingPasswordFile = null; + this.state = INITIAL; + this.notify(); + } + + /** Mark the current edit state as saved; clears the dirty indicator. */ + savedPosition(): Command | null { + this.history.breakCoalescing(); + return this.history.peekUndo(); + } + + markSaved(position?: Command | null): void { + // Break the coalesce burst so a post-save keystroke is a new dirtying step. + this.history.breakCoalescing(); + const saved = position === undefined ? this.history.peekUndo() : position; + this.savedTop = saved; + this.bakedDirty = false; + this.patch({ dirty: this.isDirty() }); + } + + /** Apply a command via the history stack, re-snapshot, and notify. */ + dispatch(cmd: Command): void { + if (!this.doc) return; + this.history.execute(cmd, this.doc); + this.resnapshot(); + this.patch({ dirty: this.isDirty() }); + this.schedulePositionRefresh(); + } + + private schedulePositionRefresh(): void { + if (typeof window === "undefined") return; + if (this.positionRefreshTimer !== null) { + window.clearTimeout(this.positionRefreshTimer); + } + // Debounced, but never starved. Re-clearing the timer on every keystroke + // meant a continuous burst postponed this indefinitely, and until it runs + // the overlay has no measured pen positions for the new text - so it lays + // it out on the BROWSER's advances and the caret walks off the glyphs the + // page is actually showing, about a pixel per character, snapping back + // only when the user pauses. A full recapture of every loaded page costs + // single-digit milliseconds, so a burst can afford one every so often. + const since = Date.now() - this.lastPositionRefreshAt; + const delay = Math.min( + POSITION_REFRESH_MS, + Math.max(0, POSITION_REFRESH_MAX_STALL_MS - since), + ); + this.positionRefreshTimer = window.setTimeout(() => { + this.positionRefreshTimer = null; + this.lastPositionRefreshAt = Date.now(); + const doc = this.doc; + if (!doc) return; + const changedByPage = new Map>(); + for (const page of doc.loadedPages()) { + try { + // Positions only. `PdfiumModelSync.resyncPage` re-reads the whole + // page and would give identity-preserved RUNS too, but it re-runs + // grouping, font registration and the annotation walk on every tick + // for no gain while only positions may safely be adopted mid-edit. + const changed = PdfiumTextReader.recapturePositions(doc, page); + if (changed.size > 0) changedByPage.set(page.index, changed); + } catch { + continue; + } + } + if (changedByPage.size > 0) this.refreshRunSnapshots(changedByPage); + }, delay); + } + + // Re-read one page's geometry from the engine immediately, keeping run ids. + // The debounced refresh above calls the same thing; this is the un-debounced + // entry point for callers that need it now (and for measuring its cost). + resyncPage(pageIndex: number): ModelSyncResult | null { + const doc = this.doc; + if (!doc) return null; + try { + return PdfiumModelSync.resyncPage( + doc, + doc.page(pageIndex), + this.groupingMode, + ); + } catch { + return null; + } + } + + // Publish fresh snapshots ONLY for runs whose positions moved. Re-snapshotting + // every run made the periodic tick re-render every overlay on every page per + // keystroke; reusing identities lets React skip the untouched ones. + private refreshRunSnapshots(changedByPage: Map>): void { + const doc = this.doc; + if (!doc) return; + this.patch({ + pages: this.state.pages.map((p) => { + const changed = changedByPage.get(p.pageIndex); + if (!changed || changed.size === 0) return p; + const live = doc.page(p.pageIndex); + const prevById = new Map(p.runs.map((s) => [s.id, s])); + return { + ...p, + runs: live.runs.map((r) => + changed.has(r) + ? r.snapshot() + : (prevById.get(r.id) ?? r.snapshot()), + ), + }; + }), + }); + } + + undo(): void { + if (!this.doc) return; + try { + this.history.undo(this.doc); + } catch { + this.recoverFromBrokenStep(); + return; + } + this.resnapshot(); + this.patch({ dirty: this.isDirty() }); + } + + redo(): void { + if (!this.doc) return; + try { + this.history.redo(this.doc); + } catch { + this.recoverFromBrokenStep(); + return; + } + this.resnapshot(); + this.patch({ dirty: this.isDirty() }); + } + + // A half-applied command leaves the run model describing objects that no + // longer match the page, so rebuild it from PDFium rather than guess. + private recoverFromBrokenStep(): void { + const doc = this.doc; + if (!doc) return; + this.repopulateAllPages(doc, this.state.groupingMode); + // Rebuilt runs get fresh ids, so no existing history entry can apply. + this.history.clear(); + this.savedTop = null; + this.bakedDirty = true; + this.selection.clear(); + this.resnapshot(); + this.patch({ dirty: true }); + } + + /** Drop every page's run model and read it back from the document. */ + private repopulateAllPages(doc: EditorDocument, mode: GroupingMode): void { + for (const page of doc.loadedPages()) { + if (!page.loaded) continue; + page.flushGenerate(doc.module); + page.loaded = false; + page.setRuns([]); + page.setImages([]); + PdfiumTextReader.populate(doc, page, mode); + } + } + + /** Revert every edit in history; document returns to its load state. */ + resetAll(): void { + if (!this.doc) return; + this.history.undoAll(this.doc); + this.resnapshot(); + this.patch({ dirty: this.isDirty() }); + } + + /** Re-read the model into a fresh page-snapshot array and publish it. */ + resnapshot(): void { + if (!this.doc) return; + let changed = false; + const doc = this.doc; + const pages: PageSnapshot[] = this.state.pages.map((p) => { + const live = doc.page(p.pageIndex); + if (live.revision === p.revision) return p; + changed = true; + return { + ...p, + dirty: live.dirty, + revision: live.revision, + runs: live.runs.map((r) => r.snapshot()), + images: live.images.map((img) => img.snapshot()), + }; + }); + if (!changed) return; + this.patch({ pages }); + } + + // Push a fresh page snapshot list into the store - called by the React loader + // once `PdfiumTextReader` finishes for a page. + publishPages(pages: PageSnapshot[]): void { + this.patch({ pages }); + } + + /** Document-level dirty bit. */ + private isDirty(): boolean { + if (!this.doc) return false; + return this.bakedDirty || this.history.peekUndo() !== this.savedTop; + } + + private patch(partial: Partial): void { + this.state = { ...this.state, ...partial }; + this.notify(); + } + + private notify(): void { + // Snapshot listeners before iterating. + const snapshot = Array.from(this.listeners); + for (const l of snapshot) { + try { + l(this.state); + } catch { + /* one listener throwing must not stop the rest */ + } + } + } + + private disposeDocumentIfAny(): void { + if (this.doc) { + try { + this.doc.dispose(); + } catch { + /* best-effort */ + } + this.doc = null; + } + } + + dispose(): void { + this.disposeDocumentIfAny(); + this.listeners.clear(); + } +} diff --git a/frontend/editor/src/core/tools/pdfTextEditor/store/HistoryStack.ts b/frontend/editor/src/core/tools/pdfTextEditor/store/HistoryStack.ts new file mode 100644 index 0000000000..a403e4a56e --- /dev/null +++ b/frontend/editor/src/core/tools/pdfTextEditor/store/HistoryStack.ts @@ -0,0 +1,147 @@ +import type { Command } from "@app/tools/pdfTextEditor/commands/Command"; +import { CompositeCommand } from "@app/tools/pdfTextEditor/commands/CompositeCommand"; +import type { EditorDocument } from "@app/tools/pdfTextEditor/model/EditorDocument"; + +const DEFAULT_LIMIT = 200; + +// Commands sharing a coalesce key that execute within this many ms of each +// other are grouped into one undo step. contentEditable fires several `input`. +const COALESCE_WINDOW_MS = 600; + +/** A command threw mid-step, so the document no longer matches the history. */ +export class HistoryStepError extends Error { + readonly phase: "apply" | "revert"; + readonly cause: unknown; + + constructor(phase: "apply" | "revert", cause: unknown) { + super(`Command failed to ${phase}`); + this.name = "HistoryStepError"; + this.phase = phase; + this.cause = cause; + } +} + +// LIFO command history for undo/redo. - `execute` applies the command and +// pushes it. +export class HistoryStack { + private readonly undoStack: Command[]; + private readonly redoStack: Command[]; + private readonly limit: number; + /** Coalesce key of the last executed command, or null if not coalescable. */ + private lastCoalesceKey: string | null = null; + /** Timestamp (ms) of the last execute(), for the coalesce time window. */ + private lastExecuteAt = 0; + + constructor(limit: number = DEFAULT_LIMIT) { + this.undoStack = []; + this.redoStack = []; + this.limit = limit; + } + + get canUndo(): boolean { + return this.undoStack.length > 0; + } + + get canRedo(): boolean { + return this.redoStack.length > 0; + } + + size(): { undo: number; redo: number } { + return { undo: this.undoStack.length, redo: this.redoStack.length }; + } + + /** The command a plain undo would revert next (null when empty). */ + peekUndo(): Command | null { + return this.undoStack[this.undoStack.length - 1] ?? null; + } + + execute(cmd: Command, doc: EditorDocument): void { + // Read the clock BEFORE apply: the window is meant to measure the user's + // idle time between edits. + const startedAt = Date.now(); + cmd.apply(doc); + const key = cmd.coalesceKey?.() ?? null; + const top = this.undoStack[this.undoStack.length - 1]; + // The command a merge would join. Unwrap a group to its most recent + // child so the hook compares against a real edit, not the wrapper. + const previous = (top instanceof CompositeCommand ? top.last : top) ?? null; + // Group with the previous command when it shares a coalesce key and ran + // within the time window. + const inWindow = + startedAt - this.lastExecuteAt <= COALESCE_WINDOW_MS || + cmd.coalesceIgnoresTimeWindow?.(previous) === true; + if (key !== null && key === this.lastCoalesceKey && top && inWindow) { + if (top instanceof CompositeCommand) { + top.push(cmd); + } else { + this.undoStack[this.undoStack.length - 1] = new CompositeCommand([ + top, + cmd, + ]); + } + } else { + this.undoStack.push(cmd); + if (this.undoStack.length > this.limit) { + this.undoStack.shift(); + } + } + this.lastCoalesceKey = key; + // Stamped after apply() so the next execute() measures the idle gap. + this.lastExecuteAt = Date.now(); + this.redoStack.length = 0; + } + + undo(doc: EditorDocument): Command | null { + const cmd = this.undoStack.pop(); + if (!cmd) return null; + try { + cmd.revert(doc); + } catch (err) { + // The command is already popped and the document is in an unknown + // state, so the caller has to rebuild rather than keep undoing. + this.lastCoalesceKey = null; + throw new HistoryStepError("revert", err); + } + this.redoStack.push(cmd); + // End the coalescing burst - a later edit starts a fresh undo step. + this.lastCoalesceKey = null; + return cmd; + } + + redo(doc: EditorDocument): Command | null { + const cmd = this.redoStack.pop(); + if (!cmd) return null; + try { + cmd.apply(doc); + } catch (err) { + this.lastCoalesceKey = null; + throw new HistoryStepError("apply", err); + } + this.undoStack.push(cmd); + this.lastCoalesceKey = null; + return cmd; + } + + clear(): void { + this.undoStack.length = 0; + this.redoStack.length = 0; + this.lastCoalesceKey = null; + } + + /** End the coalescing burst so the next execute starts a fresh undo step. */ + breakCoalescing(): void { + this.lastCoalesceKey = null; + } + + /** Revert every command currently on the undo stack, in reverse order. */ + undoAll( + doc: import("@app/tools/pdfTextEditor/model/EditorDocument").EditorDocument, + ): number { + let count = 0; + while (this.undoStack.length > 0) { + this.undo(doc); + count += 1; + } + return count; + } +} diff --git a/frontend/editor/src/core/tools/pdfTextEditor/store/Selection.ts b/frontend/editor/src/core/tools/pdfTextEditor/store/Selection.ts new file mode 100644 index 0000000000..4bb76362f8 --- /dev/null +++ b/frontend/editor/src/core/tools/pdfTextEditor/store/Selection.ts @@ -0,0 +1,113 @@ +import type { SelectionState } from "@app/tools/pdfTextEditor/types"; + +// Singleton "find highlight" state, kept off the SelectionState (which is used +// for edit commands) so search highlights survive normal selection changes. +export class FindHighlight { + private id: string | null = null; + private listeners: Set<(id: string | null) => void> = new Set(); + + set(runId: string | null): void { + if (this.id === runId) return; + this.id = runId; + // Snapshot + guard so one throwing/unsubscribing listener can't abort + // notification of the rest (see EditorStore.notify for the rationale). + for (const l of Array.from(this.listeners)) { + try { + l(this.id); + } catch { + /* one listener throwing must not stop the rest */ + } + } + } + get(): string | null { + return this.id; + } + subscribe(l: (id: string | null) => void): () => void { + this.listeners.add(l); + return () => this.listeners.delete(l); + } +} + +export class Selection { + private state: SelectionState; + private listeners: Set<(s: SelectionState) => void>; + /** Yellow highlight for the current find-bar match. */ + readonly highlight: FindHighlight; + + constructor() { + this.state = { runIds: [], imageIds: [], caret: null }; + this.listeners = new Set(); + this.highlight = new FindHighlight(); + } + + get value(): SelectionState { + return this.state; + } + + set(next: SelectionState): void { + this.state = next; + this.notify(); + } + + clear(): void { + this.set({ runIds: [], imageIds: [], caret: null }); + } + + selectOne(runId: string, caret: number | null = null): void { + this.set({ runIds: [runId], imageIds: [], caret }); + } + + toggle(runId: string): void { + if (this.state.runIds.includes(runId)) { + this.set({ + ...this.state, + runIds: this.state.runIds.filter((id) => id !== runId), + caret: null, + }); + } else { + this.set({ + ...this.state, + runIds: [...this.state.runIds, runId], + caret: null, + }); + } + } + + selectImage(imageId: string): void { + this.set({ runIds: [], imageIds: [imageId], caret: null }); + } + + /** + * Replace the selection with `runIds`, or union them into it when additive + * (an extending rectangle-select). Additive keeps order, dedupes, and leaves + * any selected images alone. + */ + selectMany(runIds: string[], additive = false): void { + if (!additive) { + this.set({ runIds: [...runIds], imageIds: [], caret: null }); + return; + } + const merged = [...this.state.runIds]; + for (const id of runIds) { + if (!merged.includes(id)) merged.push(id); + } + this.set({ ...this.state, runIds: merged, caret: null }); + } + + subscribe(listener: (s: SelectionState) => void): () => void { + this.listeners.add(listener); + return () => this.listeners.delete(listener); + } + + private notify(): void { + // Snapshot + guard: a subscriber may synchronously unsubscribe others + // or throw; iterating the live Set would skip listeners or abort early. + for (const l of Array.from(this.listeners)) { + try { + l(this.state); + } catch { + /* one listener throwing must not stop the rest */ + } + } + } +} diff --git a/frontend/editor/src/core/tools/pdfTextEditor/types.ts b/frontend/editor/src/core/tools/pdfTextEditor/types.ts new file mode 100644 index 0000000000..6a1d6f304e --- /dev/null +++ b/frontend/editor/src/core/tools/pdfTextEditor/types.ts @@ -0,0 +1,146 @@ +/** Shared types for the PDF text editor. */ + +import type { DisplayTransformData } from "@app/tools/pdfTextEditor/model/DisplayTransform"; +import type { AnnotationBox } from "@app/tools/pdfTextEditor/model/AnnotationBox"; + +export interface RGBA { + r: number; // 0..255 + g: number; + b: number; + a: number; +} + +export interface PageRect { + x: number; + y: number; + width: number; + height: number; +} + +export interface Affine { + a: number; + b: number; + c: number; + d: number; + e: number; + f: number; +} + +export type FontStyle = "normal" | "italic"; +export type FontWeight = "normal" | "bold"; + +// How the reader clusters source text objects into editable runs. - "auto": run +// `LineGrouper` then `ParagraphGrouper`. +export type GroupingMode = "auto" | "line"; + +// How an editable text box grows when its content exceeds the source width: +// "grow" widens to the right. +export type WidthMode = "grow" | "wrap"; + +export interface FontDescriptor { + /** Stable id used internally for ref equality */ + id: string; + family: string; + style: FontStyle; + weight: FontWeight; + /** Whether the font is fully embedded in our bundle */ + bundled: boolean; +} + +export interface TextRunSnapshot { + id: string; + pageIndex: number; + bounds: PageRect; + /** Affine that places the run in page coordinates */ + matrix: Affine; + text: string; + fontId: string; + fontSize: number; + fill: RGBA; + /** True if PDFium says the source PDF subsetted this run's font */ + fontSubset: boolean; + /** PDF text render mode (Tr). 0/absent = normal fill; 3 = invisible. */ + renderMode?: number; + /** Outline colour, when the run's render mode strokes its glyphs. */ + stroke?: RGBA; + /** Outline width in PDF points; 0/absent = hairline or unstroked. */ + strokeWidth?: number; + /** Engine pen origins/ends per code unit; present only while still current. */ + charStartsX?: number[]; + charEndsX?: number[]; + /** Inferred letter-spacing (Tc footprint) in PDF points; 0/absent = none. */ + charSpacingPt?: number; + /** > 0 when this run represents a multi-line paragraph. */ + paragraphLineHeight?: number; + /** Member-line count when paragraph (== 1 implies a single line). */ + paragraphLineCount?: number; + /** Line-slot count; what line alignment actually requires 2 of. */ + paragraphSlotCount?: number; + paragraphBaselines?: number[]; + paragraphLineLefts?: number[]; + // Editor-only metadata: when true the run cannot be selected or edited via + // mouse/keyboard. + locked?: boolean; +} + +export interface ImageObjectSnapshot { + id: string; + pageIndex: number; + bounds: PageRect; + matrix: Affine; + /** Editor-only: see TextRunSnapshot.locked. */ + locked?: boolean; +} + +export interface PageSnapshot { + pageIndex: number; + width: number; + height: number; + /** True when there are uncommitted edits on this page */ + dirty: boolean; + /** Monotonic counter that increments on every commit. */ + revision: number; + runs: TextRunSnapshot[]; + images: ImageObjectSnapshot[]; + // Text-carrying annotations: drawn by the canvas, outside the editable + // object tree. Absent until the page has been read. + annotations?: AnnotationBox[]; + // Raw-PDF -> display (CropBox/rotation) transform for the screen boundary. + display: DisplayTransformData; +} + +export interface SelectionState { + runIds: string[]; + /** Selected image object ids. */ + imageIds: string[]; + /** Caret position when exactly one run is selected and the user is typing */ + caret: number | null; +} + +export interface ToolbarState { + fontFamily: string | null; + fontSize: number | null; + fill: RGBA | null; + bold: boolean; + italic: boolean; + /** + * Whether an italic cut is actually reachable for every selected run - a + * base-14 flip, or an installed face of the run's own family. False disables + * the control instead of silently substituting Helvetica for the real font. + */ + canItalic: boolean; + /** Glyph outline colour across the selection; null when unset or mixed. */ + stroke: RGBA | null; + /** Glyph outline width in points; null when mixed. 0 means no outline. */ + strokeWidth: number | null; + /** Mixed-value indicator for multi-select */ + mixed: { + fontFamily: boolean; + fontSize: boolean; + fill: boolean; + bold: boolean; + italic: boolean; + stroke: boolean; + strokeWidth: boolean; + }; +} diff --git a/frontend/editor/src/core/tools/pdfTextEditor/util/canvasBackground.ts b/frontend/editor/src/core/tools/pdfTextEditor/util/canvasBackground.ts new file mode 100644 index 0000000000..c589d73432 --- /dev/null +++ b/frontend/editor/src/core/tools/pdfTextEditor/util/canvasBackground.ts @@ -0,0 +1,83 @@ +/** + * Read the page's own background colour straight from the rendered bitmap. + * + * The editing mask used to pick between near-white and near-black from the + * TEXT colour alone, so a run on a coloured page got a grey band across it. + * It was also translucent, which let the original glyphs ghost through the + * replacement. Sampling the canvas gives the real colour to paint, opaquely. + */ + +/** Strip height either side of the glyph band that is sampled for background. */ +const MARGIN_RATIO = 0.22; +/** Pixels stepped over while sampling; keeps the read cheap on wide runs. */ +const STEP = 3; + +export interface Rgb { + r: number; + g: number; + b: number; +} + +/** `rgb(r, g, b)` - always fully opaque, so nothing underneath shows through. */ +export function toOpaqueCss(c: Rgb): string { + return `rgb(${c.r}, ${c.g}, ${c.b})`; +} + +/** + * Most common colour in the strips directly above and below the run's glyphs. + * Returns null when the canvas cannot be read (tainted, zero-sized, no 2d). + */ +export function sampleRunBackground( + canvas: HTMLCanvasElement, + rectInCanvasPx: { x: number; y: number; width: number; height: number }, +): Rgb | null { + const { x, y, width, height } = rectInCanvasPx; + if (width < 1 || height < 1) return null; + const ctx = canvas.getContext("2d", { willReadFrequently: true }); + if (!ctx) return null; + + const margin = Math.max(1, Math.round(height * MARGIN_RATIO)); + const bands = [ + { top: Math.round(y), h: margin }, + { top: Math.round(y + height - margin), h: margin }, + ]; + + const buckets = new Map< + string, + { r: number; g: number; b: number; n: number } + >(); + for (const band of bands) { + const top = Math.max(0, Math.min(canvas.height - 1, band.top)); + const h = Math.max(1, Math.min(band.h, canvas.height - top)); + const left = Math.max(0, Math.min(canvas.width - 1, Math.round(x))); + const w = Math.max(1, Math.min(Math.round(width), canvas.width - left)); + let data: Uint8ClampedArray; + try { + data = ctx.getImageData(left, top, w, h).data; + } catch { + return null; + } + for (let i = 0; i < data.length; i += 4 * STEP) { + const r = data[i]; + const g = data[i + 1]; + const b = data[i + 2]; + const key = `${r & 0xf8},${g & 0xf8},${b & 0xf8}`; + const hit = buckets.get(key); + if (hit) { + hit.r += r; + hit.g += g; + hit.b += b; + hit.n += 1; + } else buckets.set(key, { r, g, b, n: 1 }); + } + } + + let best: { r: number; g: number; b: number; n: number } | null = null; + for (const v of buckets.values()) if (!best || v.n > best.n) best = v; + if (!best) return null; + return { + r: Math.round(best.r / best.n), + g: Math.round(best.g / best.n), + b: Math.round(best.b / best.n), + }; +} diff --git a/frontend/editor/src/core/tools/pdfTextEditor/util/deviceFontEmbed.ts b/frontend/editor/src/core/tools/pdfTextEditor/util/deviceFontEmbed.ts new file mode 100644 index 0000000000..5a09a6d35c --- /dev/null +++ b/frontend/editor/src/core/tools/pdfTextEditor/util/deviceFontEmbed.ts @@ -0,0 +1,292 @@ +import type { EditorDocument } from "@app/tools/pdfTextEditor/model/EditorDocument"; +import type { Page } from "@app/tools/pdfTextEditor/model/Page"; +import type { RGBA } from "@app/tools/pdfTextEditor/types"; +import { FontRef } from "@app/tools/pdfTextEditor/model/FontRef"; +import { parseTrueTypeCmap } from "@app/tools/pdfTextEditor/charcode/CmapResolver"; +import { writeUtf16 } from "@app/services/pdfiumService"; +import { + getLocalFontBytes, + loadLocalFontBytes, +} from "@app/tools/pdfTextEditor/util/localFonts"; +import { + isBoldFamily, + isItalicFamily, +} from "@app/tools/pdfTextEditor/util/fontFamily"; + +// Embed the fonts installed on the user's device instead of substituting the +// nearest standard face. Reading the file is async, so the emit uses the cache. + +// Composite (CID) TrueType, so SetText can address code points beyond 255. +const FPDF_FONT_TRUETYPE = 2; +const DEVICE_FONT_ID_PREFIX = "__device_font:"; + +/** Owned-font id for a family, stable across emits of the same document. */ +export function deviceFontIdFor(family: string): string { + return `${DEVICE_FONT_ID_PREFIX}${family.trim().toLowerCase()}`; +} + +interface ExtendedPdfiumRuntime { + HEAPU8: Uint8Array; +} + +interface DeviceFontModule { + FPDFText_LoadFont?: ( + doc: number, + data: number, + size: number, + fontType: number, + cid: boolean, + ) => number; + FPDFFont_Close?: (font: number) => void; + FPDFPageObj_CreateTextObj?: ( + doc: number, + font: number, + size: number, + ) => number; + FPDFPageObj_GetBounds?: ( + obj: number, + left: number, + bottom: number, + right: number, + top: number, + ) => boolean; +} + +/** Parsed cmap per family, so coverage is computed once per session. */ +const coverageByFamily = new Map | null>(); +/** Families PDFium already refused for a document; never retried. */ +let refusedByDoc = new WeakMap>(); +/** Successful device-font emits per document, keyed by owned-font id. */ +let emitCountByDoc = new WeakMap>(); + +function refusedFor(doc: EditorDocument): Set { + let set = refusedByDoc.get(doc); + if (!set) { + set = new Set(); + refusedByDoc.set(doc, set); + } + return set; +} + +/** Test hook: drop the per-session coverage and per-document memos. */ +export function resetDeviceFontEmbedCache(): void { + coverageByFamily.clear(); + refusedByDoc = new WeakMap>(); + emitCountByDoc = new WeakMap>(); +} + +// True if the face covers every non-whitespace code point. Fails open, leaving +// the width self-check as the backstop. +function deviceFontCovers( + family: string, + bytes: Uint8Array, + text: string, +): boolean { + const key = deviceFontIdFor(family); + if (!coverageByFamily.has(key)) { + let parsed: Map | null = null; + try { + parsed = parseTrueTypeCmap(bytes); + } catch { + parsed = null; + } + coverageByFamily.set(key, parsed); + } + const coverage = coverageByFamily.get(key) ?? null; + if (!coverage) return true; + for (const ch of text) { + if (/\s/.test(ch)) continue; + const cp = ch.codePointAt(0); + if (cp === undefined || !coverage.has(cp)) return false; + } + return true; +} + +// Read the family's font file so a later synchronous emit can embed it. The UI +// must AWAIT this before dispatching a font-family change. +export async function ensureDeviceFontReady(family: string): Promise { + const bytes = await loadLocalFontBytes(family); + return !!bytes && bytes.length > 0; +} + +/** Whether a synchronous emit can embed this family right now. */ +export function isDeviceFontReady(family: string): boolean { + const bytes = getLocalFontBytes(family); + return !!bytes && bytes.length > 0; +} + +/** Whether `family` is already embedded in `doc`. */ +export function isDeviceFontEmbedded( + doc: EditorDocument, + family: string, +): boolean { + return !!doc.ownedFont(deviceFontIdFor(family)); +} + +/** How many objects this document has emitted in `family`'s embedded face. */ +export function deviceFontEmitCount( + doc: EditorDocument, + family: string, +): number { + return emitCountByDoc.get(doc)?.get(deviceFontIdFor(family)) ?? 0; +} + +function recordEmit(doc: EditorDocument, family: string): void { + let counts = emitCountByDoc.get(doc); + if (!counts) { + counts = new Map(); + emitCountByDoc.set(doc, counts); + } + const key = deviceFontIdFor(family); + counts.set(key, (counts.get(key) ?? 0) + 1); +} + +// Embed `family` into `doc` once and return its font handle, or 0. Freed with +// the document, along with its backing WASM buffer. +export function loadDeviceFontInto( + doc: EditorDocument, + family: string, +): number { + const id = deviceFontIdFor(family); + const existing = doc.ownedFont(id); + if (existing) return existing.pointer; + const refused = refusedFor(doc); + // A refusal is permanent for this document; retrying would re-malloc the + // whole font file on every keystroke. + if (refused.has(id)) return 0; + const bytes = getLocalFontBytes(family); + if (!bytes || bytes.length === 0) return 0; + + const m = doc.module; + const mod = m as unknown as DeviceFontModule; + if (typeof mod.FPDFText_LoadFont !== "function") return 0; + const len = bytes.length; + const ptr = m.pdfium.wasmExports.malloc(len); + if (!ptr) return 0; + try { + (m.pdfium as typeof m.pdfium & ExtendedPdfiumRuntime).HEAPU8.set( + bytes, + ptr, + ); + const fontPtr = mod.FPDFText_LoadFont( + doc.docPtr, + ptr, + len, + FPDF_FONT_TRUETYPE, + true, + ); + if (!fontPtr) { + refused.add(id); + m.pdfium.wasmExports.free(ptr); + return 0; + } + doc.registerOwnedFont( + new FontRef({ + id, + descriptor: { + id, + family, + style: isItalicFamily(family) ? "italic" : "normal", + weight: isBoldFamily(family) ? "bold" : "normal", + bundled: false, + }, + pointer: fontPtr, + owned: true, + // Free BOTH the font handle and its backing buffer on doc dispose. + closeFn: (p) => { + try { + mod.FPDFFont_Close?.(p); + } catch { + /* best-effort */ + } + try { + m.pdfium.wasmExports.free(ptr); + } catch { + /* best-effort */ + } + }, + }), + ); + return fontPtr; + } catch { + refused.add(id); + try { + m.pdfium.wasmExports.free(ptr); + } catch { + /* best-effort */ + } + return 0; + } +} + +/** Right edge (PDF points) of an object's visible bbox, or 0 if unmeasurable. */ +function measureRightEdge(m: EditorDocument["module"], ptr: number): number { + const mod = m as unknown as DeviceFontModule; + if (typeof mod.FPDFPageObj_GetBounds !== "function") return 0; + const l = m.pdfium.wasmExports.malloc(4); + const b = m.pdfium.wasmExports.malloc(4); + const r = m.pdfium.wasmExports.malloc(4); + const t = m.pdfium.wasmExports.malloc(4); + try { + if (!mod.FPDFPageObj_GetBounds(ptr, l, b, r, t)) return 0; + return m.pdfium.getValue(r, "float"); + } catch { + return 0; + } finally { + m.pdfium.wasmExports.free(l); + m.pdfium.wasmExports.free(b); + m.pdfium.wasmExports.free(r); + m.pdfium.wasmExports.free(t); + } +} + +// Emit one text object in `family`'s embedded face. Returns 0 when the font is +// uncached, refused, lacks the glyphs, or measured ~0-wide - caller substitutes. +export function emitDeviceFontTextObject( + doc: EditorDocument, + page: Page, + family: string, + text: string, + size: number, + fill: RGBA, + x: number, + y: number, +): number { + if (text.length === 0) return 0; + const bytes = getLocalFontBytes(family); + if (!bytes || bytes.length === 0) return 0; + if (!deviceFontCovers(family, bytes, text)) return 0; + const fontPtr = loadDeviceFontInto(doc, family); + if (!fontPtr) return 0; + const m = doc.module; + const create = (m as unknown as DeviceFontModule).FPDFPageObj_CreateTextObj; + if (typeof create !== "function") return 0; + const fp = create(doc.docPtr, fontPtr, size); + if (!fp) return 0; + const tp = writeUtf16(m, text); + try { + m.FPDFText_SetText(fp, tp); + } finally { + m.pdfium.wasmExports.free(tp); + } + m.FPDFPageObj_SetFillColor(fp, fill.r, fill.g, fill.b, fill.a); + m.FPDFPageObj_Transform(fp, 1, 0, 0, 1, x, y); + m.FPDFPage_InsertObject(page.pagePtr, fp); + const right = measureRightEdge(m, fp); + const visible = text.replace(/\s+/g, "").length; + if (visible > 0 && right - x < visible * size * 0.05) { + try { + m.FPDFPage_RemoveObject(page.pagePtr, fp); + } catch { + /* best-effort */ + } + try { + m.FPDFPageObj_Destroy(fp); + } catch { + /* best-effort */ + } + return 0; + } + recordEmit(doc, family); + return fp; +} diff --git a/frontend/editor/src/core/tools/pdfTextEditor/util/documentRisks.ts b/frontend/editor/src/core/tools/pdfTextEditor/util/documentRisks.ts new file mode 100644 index 0000000000..0556a8957f --- /dev/null +++ b/frontend/editor/src/core/tools/pdfTextEditor/util/documentRisks.ts @@ -0,0 +1,83 @@ +import type { EditorDocument } from "@app/tools/pdfTextEditor/model/EditorDocument"; +import { getDroppedBase14Chars } from "@app/tools/pdfTextEditor/commands/editTextHelpers"; + +// Only losses that are HIGH-confidence under the save path: signatures (the +// save goes incremental), XFA, encryption, and characters an edit had to drop. +export interface SaveRisks { + signatures: number; + xfaForm: boolean; + encrypted: boolean; + /** Distinct visible chars this session's edits couldn't render and dropped. */ + droppedChars: string[]; +} + +/** Inspect the open document for content a full rewrite would damage. */ +export function detectSaveRisks(doc: EditorDocument): SaveRisks { + const m = doc.module; + let signatures = 0; + let xfaForm = false; + let encrypted = false; + try { + signatures = Math.max(0, m.FPDF_GetSignatureCount(doc.docPtr)); + } catch { + /* API absent in older builds - treat as no signatures */ + } + try { + // FORMTYPE: 0 none, 1 acroform, 2 xfa-full, 3 xfa-foreground. + const formType = m.FPDF_GetFormType(doc.docPtr); + xfaForm = formType === 2 || formType === 3; + } catch { + /* API absent - treat as no XFA */ + } + try { + // Revision -1 means unencrypted; >= 0 means an encryption dict is present. + const rev = m.FPDF_GetSecurityHandlerRevision(doc.docPtr); + encrypted = rev >= 0; + } catch { + /* API absent - treat as unencrypted */ + } + return { + signatures, + xfaForm, + encrypted, + droppedChars: getDroppedBase14Chars(), + }; +} + +export function hasSaveRisks(r: SaveRisks): boolean { + return ( + r.signatures > 0 || r.xfaForm || r.encrypted || r.droppedChars.length > 0 + ); +} + +/** Human-readable bullet lines describing what the save would damage. */ +export function describeSaveRisks(r: SaveRisks): string[] { + const out: string[] = []; + if (r.signatures > 0) { + const subject = + r.signatures === 1 + ? "This document carries a digital signature" + : `This document carries ${r.signatures} digital signatures`; + out.push( + `${subject}. Your changes are appended as a new revision, so the signed version stays ` + + "verifiable, but the document will report as modified since it was signed.", + ); + } + if (r.xfaForm) out.push("Interactive XFA form data may be lost."); + if (r.encrypted) { + out.push( + "This PDF is encrypted; the saved copy will NOT be encrypted (password and access restrictions are removed).", + ); + } + if (r.droppedChars.length > 0) { + const shown = r.droppedChars.slice(0, 12).join(" "); + const more = + r.droppedChars.length > 12 + ? ` (+${r.droppedChars.length - 12} more)` + : ""; + out.push( + `Some characters could not be embedded in any available font and were dropped: ${shown}${more}`, + ); + } + return out; +} diff --git a/frontend/editor/src/core/tools/pdfTextEditor/util/dom.ts b/frontend/editor/src/core/tools/pdfTextEditor/util/dom.ts new file mode 100644 index 0000000000..a9e1a85e84 --- /dev/null +++ b/frontend/editor/src/core/tools/pdfTextEditor/util/dom.ts @@ -0,0 +1,64 @@ +/** True when focus is in a typing surface (contenteditable, input, etc). */ +export function isFocusInContentEditable(): boolean { + const el = document.activeElement as HTMLElement | null; + if (!el) return false; + if (el.isContentEditable) return true; + const tag = el.tagName; + return tag === "INPUT" || tag === "TEXTAREA" || tag === "SELECT"; +} + +// True when focus is in a FORM field (Find/Replace/password inputs) as opposed +// to a run's contenteditable. +export function isFocusInFormField(): boolean { + const el = document.activeElement as HTMLElement | null; + if (!el) return false; + const tag = el.tagName; + return tag === "INPUT" || tag === "TEXTAREA" || tag === "SELECT"; +} + +// Find the page index whose midpoint is closest to the viewport's vertical +// centre. +export function findVisiblePageIndex(): number { + const pages = pageElements(); + if (pages.length === 0) return 0; + const midY = window.innerHeight / 2; + let best = 0; + let bestDist = Number.POSITIVE_INFINITY; + pages.forEach((el, i) => { + const rect = el.getBoundingClientRect(); + const dist = Math.abs(rect.top + rect.height / 2 - midY); + if (dist < bestDist) { + bestDist = dist; + best = i; + } + }); + return best; +} + +// The TRUE page index of the page nearest the viewport centre - unlike {@link +// findVisiblePageIndex}, which returns a DOM-array position. +export function visiblePageNumber(): number { + const pages = pageElements(); + if (pages.length === 0) return 0; + const midY = window.innerHeight / 2; + let best = 0; + let bestDist = Number.POSITIVE_INFINITY; + for (const el of pages) { + const n = Number((el.dataset.testid ?? "").replace("pdf-editor-page-", "")); + if (!Number.isFinite(n)) continue; + const rect = el.getBoundingClientRect(); + const dist = Math.abs(rect.top + rect.height / 2 - midY); + if (dist < bestDist) { + bestDist = dist; + best = n; + } + } + return best; +} + +/** All real page surfaces in DOM order, skipping placeholders/error tiles. */ +export function pageElements(): HTMLElement[] { + return Array.from( + document.querySelectorAll('[data-testid^="pdf-editor-page-"]'), + ).filter((el) => /^pdf-editor-page-\d+$/.test(el.dataset.testid ?? "")); +} diff --git a/frontend/editor/src/core/tools/pdfTextEditor/util/embeddedFace.ts b/frontend/editor/src/core/tools/pdfTextEditor/util/embeddedFace.ts new file mode 100644 index 0000000000..09cb4cfcaa --- /dev/null +++ b/frontend/editor/src/core/tools/pdfTextEditor/util/embeddedFace.ts @@ -0,0 +1,154 @@ +import type { WrappedPdfiumModule } from "@embedpdf/pdfium"; + +// The overlay used to collapse every document font to one of three generic CSS +// stacks, so editing text visibly changed its shape. PDFium will hand back the +// face it actually rendered with - embedded, or the one it substituted - and a +// FontFace built from those bytes matches the bitmap underneath exactly. + +const faces = new Map(); +/** Pointers already tried, so a font that cannot load is not retried per run. */ +const attempted = new Set(); +const loaded = new Set(); +const listeners = new Set<() => void>(); +let generation = 0; + +/** CSS family name for a font pointer. Stable whether or not it ever loads. */ +export function embeddedFaceFamily(fontPtr: number): string { + return `pdfface-${fontPtr}`; +} + +export function registerEmbeddedFace( + m: WrappedPdfiumModule, + fontPtr: number, +): void { + if (!fontPtr || attempted.has(fontPtr)) return; + attempted.add(fontPtr); + if (typeof document === "undefined" || typeof FontFace === "undefined") { + return; + } + const bytes = readFontData(m, fontPtr); + if (!bytes || bytes.length === 0) return; + if (faceBytesHeld + bytes.length > MAX_TOTAL_FACE_BYTES) return; + const held = bytes.length; + const bornAt = generation; + faceBytesHeld += held; + + let face: FontFace; + try { + face = new FontFace(embeddedFaceFamily(fontPtr), bytes); + } catch { + faceBytesHeld -= held; + return; + } + faces.set(fontPtr, face); + void face + .load() + .then(() => { + if (bornAt !== generation) return; + document.fonts.add(face); + loaded.add(fontPtr); + notifyFaceLoaded(); + }) + .catch(() => { + faces.delete(fontPtr); + if (bornAt === generation) faceBytesHeld -= held; + }); +} + +export function isEmbeddedFaceReady(fontPtr: number): boolean { + return loaded.has(fontPtr); +} + +export function onEmbeddedFaceLoaded(listener: () => void): () => void { + listeners.add(listener); + return () => { + listeners.delete(listener); + }; +} + +function notifyFaceLoaded(): void { + for (const listener of [...listeners]) { + try { + listener(); + } catch { + continue; + } + } +} + +function isLoadableFaceHeader(head: Uint8Array): boolean { + if (head.length < 4) return false; + const tag = String.fromCharCode(head[0], head[1], head[2], head[3]); + if (tag === "OTTO" || tag === "true" || tag === "wOFF" || tag === "wOF2") { + return true; + } + return ( + head[0] === 0x00 && head[1] === 0x01 && head[2] === 0x00 && head[3] === 0x00 + ); +} + +/** Copy a font's face bytes out of the WASM heap. */ +function readFontData( + m: WrappedPdfiumModule, + fontPtr: number, +): Uint8Array | null { + const w = m.pdfium.wasmExports; + const lenPtr = w.malloc(4); + let size = 0; + try { + if (!m.FPDFFont_GetFontData(fontPtr, 0, 0, lenPtr)) return null; + size = m.pdfium.getValue(lenPtr, "i32"); + } catch { + return null; + } finally { + w.free(lenPtr); + } + if (size <= 0 || size > MAX_FACE_BYTES) return null; + + const buf = w.malloc(size); + const out = w.malloc(4); + try { + if (!m.FPDFFont_GetFontData(fontPtr, buf, size, out)) return null; + const heap = new Uint8Array( + (m.pdfium.wasmExports as unknown as { memory: WebAssembly.Memory }).memory + .buffer, + buf, + size, + ); + if (!isLoadableFaceHeader(heap)) return null; + // Copy into a plain ArrayBuffer: the heap view dies with the next + // allocation that grows memory, and FontFace rejects a shared buffer. + const copy = new Uint8Array(new ArrayBuffer(size)); + copy.set(heap); + return copy; + } catch { + return null; + } finally { + w.free(buf); + w.free(out); + } +} + +/** A face larger than this is a corrupt length, not a font. */ +const MAX_FACE_BYTES = 8 * 1024 * 1024; +/** Total face bytes to hold for one document, so a font-heavy file can't balloon. */ +const MAX_TOTAL_FACE_BYTES = 48 * 1024 * 1024; +let faceBytesHeld = 0; + +/** Doc-scoped reset: PDFium reuses font pointers across documents. */ +export function resetEmbeddedFaces(): void { + if (typeof document !== "undefined") { + for (const face of faces.values()) { + try { + document.fonts.delete(face); + } catch { + /* never added */ + } + } + } + faces.clear(); + attempted.clear(); + loaded.clear(); + faceBytesHeld = 0; + generation++; +} diff --git a/frontend/editor/src/core/tools/pdfTextEditor/util/exactLayout.ts b/frontend/editor/src/core/tools/pdfTextEditor/util/exactLayout.ts new file mode 100644 index 0000000000..7abc350896 --- /dev/null +++ b/frontend/editor/src/core/tools/pdfTextEditor/util/exactLayout.ts @@ -0,0 +1,136 @@ +// Turn captured pen positions into word boxes the overlay tiles at the engine's +// own origins, instead of re-flowing the line with a substitute font's advances. + +export interface ExactToken { + text: string; + /** Advance width in PDF points. */ + width: number; + /** True for a run of spaces rather than a word. */ + space: boolean; +} + +export interface ExactLine { + /** Pen X of the line's first character, in PDF points. */ + left: number; + tokens: ExactToken[]; +} + +/** Positions captured from the engine, parallel to a run's text. */ +export interface CharPositions { + /** Pen origin X per code unit; NaN where unknown. */ + starts: number[]; + /** Pen origin X plus advance per code unit; NaN where unknown. */ + ends: number[]; +} + +const SPACE = new Set([" ", "\t"]); + +// Per-line word boxes, or null when the capture cannot place the text; the +// caller then falls back to ordinary flow. +export function buildExactLines( + text: string, + positions: CharPositions, +): ExactLine[] | null { + if (text.length === 0) return null; + if (positions.starts.length !== text.length) return null; + if (positions.ends.length !== text.length) return null; + + const lines: ExactLine[] = []; + let lineStart = 0; + for (let i = 0; i <= text.length; i += 1) { + if (i < text.length && text[i] !== "\n") continue; + const built = buildLine(text, positions, lineStart, i); + // A line without usable positions makes the whole run fall back, rather + // than mixing exact and reflowed lines in one paragraph. + if (!built) return null; + lines.push(built); + lineStart = i + 1; + } + return lines.length > 0 ? lines : null; +} + +function buildLine( + text: string, + positions: CharPositions, + from: number, + to: number, +): ExactLine | null { + // The engine trims a line's trailing spaces, so they carry no position and + // are dropped here too; the caret still sees them in the text. + let end = to; + while (end > from && SPACE.has(text[end - 1])) end -= 1; + if (end === from) + return { left: firstFinite(positions.starts, from, to) ?? 0, tokens: [] }; + + const left = positions.starts[from]; + if (!Number.isFinite(left)) return null; + + const spans: Array<{ from: number; to: number; space: boolean }> = []; + let at = from; + while (at < end) { + const space = SPACE.has(text[at]); + let stop = at; + while (stop < end && SPACE.has(text[stop]) === space) stop += 1; + spans.push({ from: at, to: stop, space }); + at = stop; + } + + const tokens: ExactToken[] = []; + for (let i = 0; i < spans.length; i += 1) { + const span = spans[i]; + const width = span.space + ? (spaceGap(positions, spans, i) ?? + tokenWidth(positions, span.from, span.to)) + : tokenWidth(positions, span.from, span.to); + if (width === null) return null; + tokens.push({ + text: text.slice(span.from, span.to), + width, + space: span.space, + }); + } + if (to > end) + tokens.push({ text: text.slice(end, to), width: 0, space: true }); + return { left, tokens }; +} + +function spaceGap( + positions: CharPositions, + spans: Array<{ from: number; to: number; space: boolean }>, + i: number, +): number | null { + const next = spans[i + 1]; + if (!next) return null; + const after = positions.starts[next.from]; + const prev = spans[i - 1]; + const before = prev + ? positions.ends[prev.to - 1] + : positions.starts[spans[i].from]; + if (!Number.isFinite(before) || !Number.isFinite(after)) return null; + return after >= before ? after - before : null; +} + +// A token spans its first pen origin to the last origin-plus-advance, so boxes +// tile without drift. Both endpoints must be real, never nearest-finite. +function tokenWidth( + positions: CharPositions, + from: number, + to: number, +): number | null { + const start = positions.starts[from]; + const finish = positions.ends[to - 1]; + if (!Number.isFinite(start) || !Number.isFinite(finish)) return null; + const width = finish - start; + return width >= 0 ? width : null; +} + +function firstFinite( + values: number[], + from: number, + to: number, +): number | null { + for (let i = from; i < to; i += 1) { + if (Number.isFinite(values[i])) return values[i]; + } + return null; +} diff --git a/frontend/editor/src/core/tools/pdfTextEditor/util/exportPdf.ts b/frontend/editor/src/core/tools/pdfTextEditor/util/exportPdf.ts new file mode 100644 index 0000000000..b5b35c39aa --- /dev/null +++ b/frontend/editor/src/core/tools/pdfTextEditor/util/exportPdf.ts @@ -0,0 +1,76 @@ +import type { EditorDocument } from "@app/tools/pdfTextEditor/model/EditorDocument"; +import { preserveShadings } from "@app/tools/pdfTextEditor/pdfdoc/passes/preserveShadings"; +import { PdfiumSave } from "@app/tools/pdfTextEditor/pdfium/PdfiumSave"; + +/** Serialize the editor document to a Blob plus the download filename. */ +export async function exportToBlob( + doc: EditorDocument, + sourceName?: string | null, +): Promise<{ + blob: Blob; + filename: string; +}> { + // Nothing was ever written to a page, so PDFium has nothing to contribute: + // handing back what we opened keeps the file byte-identical. Rewriting it + // changed the bytes of 8 of this suite's 10 fixtures and inflated the small + // ones by up to 35% - for no edit at all. + if (documentIsPristine(doc)) { + return { blob: pdfBlob(doc.openedBytes), filename: exportName(sourceName) }; + } + + // A signed document is appended to rather than rewritten, so the bytes the + // signature covers are still there and still verify for their revision. + const incremental = documentIsSigned(doc); + // Must be read AFTER serialize: serialize is what marks pages regenerated, + // so reading first always yielded an empty list and silently skipped the + // shading repair on the first save after an edit. + let bytes = PdfiumSave.serialize(doc, { incremental }); + const regenerated = doc.regeneratedPages(); + + if (regenerated.length > 0 && doc.openedBytes.length > 0) { + try { + const repaired = await preserveShadings(bytes, doc.openedBytes, { + pages: regenerated, + }); + if (repaired) bytes = repaired; + } catch { + /* the unrepaired save is still a correct save */ + } + } + + return { blob: pdfBlob(bytes), filename: exportName(sourceName) }; +} + +function pdfBlob(bytes: Uint8Array): Blob { + return new Blob([bytes as unknown as ArrayBuffer], { + type: "application/pdf", + }); +} + +// Derive from the opened file's name so downloads don't all collide on +// a generic "edited.pdf". +function exportName(sourceName?: string | null): string { + const base = (sourceName ?? "").replace(/\.pdf$/i, "").trim(); + return base ? `${base}_edited.pdf` : "edited.pdf"; +} + +/** + * True when no page's content stream has been regenerated and none is waiting + * to be. `regenerated` is sticky, so this stays false for every later save in + * a session that has edited once - a second save can never hand back the + * pre-edit bytes and silently revert the first. + */ +function documentIsPristine(doc: EditorDocument): boolean { + if (doc.openedBytes.length === 0) return false; + return doc + .loadedPages() + .every((p) => !p.regenerated && !p.needsGenerateContent); +} + +function documentIsSigned(doc: EditorDocument): boolean { + try { + return doc.module.FPDF_GetSignatureCount(doc.docPtr) > 0; + } catch { + return false; + } +} diff --git a/frontend/editor/src/core/tools/pdfTextEditor/util/externalImageEdit.ts b/frontend/editor/src/core/tools/pdfTextEditor/util/externalImageEdit.ts new file mode 100644 index 0000000000..2ac6d4cd96 --- /dev/null +++ b/frontend/editor/src/core/tools/pdfTextEditor/util/externalImageEdit.ts @@ -0,0 +1,313 @@ +// Round-trip an image through the user's own editor: save the pixels as a PNG, +// then hand the bytes back every time that file is re-saved. + +export interface ExternalEditPixels { + rgba: Uint8Array | Uint8ClampedArray; + width: number; + height: number; +} + +export interface ExternalEditWatch { + readonly fileName: string; + /** Idempotent - safe to call from an unmount path that may already have run. */ + stop(): void; +} + +export type ExternalEditOutcome = + | { status: "unsupported" } + | { status: "cancelled" } + | { status: "failed"; error: unknown } + | { status: "watching"; watch: ExternalEditWatch }; + +export interface ExternalImageEditOptions { + pixels: ExternalEditPixels; + onChange: (bytes: Uint8Array) => void; + suggestedName?: string; + pollIntervalMs?: number; + onError?: (error: unknown) => void; +} + +interface WritableFile { + write(data: Uint8Array): Promise; + close(): Promise; +} + +interface PickedFile { + lastModified: number; + arrayBuffer(): Promise; +} + +interface PickedFileHandle { + name?: string; + createWritable(): Promise; + getFile(): Promise; +} + +interface SavePickerOptions { + suggestedName?: string; + types?: Array<{ description?: string; accept: Record }>; +} + +interface SavePickerHost { + showSaveFilePicker?: ( + options?: SavePickerOptions, + ) => Promise; +} + +const DEFAULT_POLL_MS = 1000; +const MIN_POLL_MS = 100; + +function savePicker(): SavePickerHost["showSaveFilePicker"] { + return (globalThis as unknown as SavePickerHost).showSaveFilePicker; +} + +/** False on Firefox and Safari, which have no File System Access write path. */ +export function isExternalImageEditSupported(): boolean { + return typeof savePicker() === "function"; +} + +export async function startExternalImageEdit( + options: ExternalImageEditOptions, +): Promise { + const picker = savePicker(); + if (typeof picker !== "function") return { status: "unsupported" }; + const suggestedName = options.suggestedName ?? "image.png"; + + let handle: PickedFileHandle | undefined; + try { + handle = await picker({ + suggestedName, + types: [{ description: "PNG image", accept: { "image/png": [".png"] } }], + }); + } catch (error) { + if (isAbort(error)) return { status: "cancelled" }; + return { status: "failed", error }; + } + if (!handle) return { status: "cancelled" }; + + let seenAt: number; + try { + const png = await encodeRgbaAsPng(options.pixels); + const writable = await handle.createWritable(); + await writable.write(png); + await writable.close(); + seenAt = (await handle.getFile()).lastModified; + } catch (error) { + return { status: "failed", error }; + } + + return { + status: "watching", + watch: watchFile(handle, handle.name ?? suggestedName, seenAt, options), + }; +} + +function watchFile( + handle: PickedFileHandle, + fileName: string, + seenAt: number, + options: ExternalImageEditOptions, +): ExternalEditWatch { + const every = Math.max( + MIN_POLL_MS, + options.pollIntervalMs ?? DEFAULT_POLL_MS, + ); + let lastSeen = seenAt; + let stopped = false; + let reading = false; + let timer: ReturnType | null = null; + + function stop(): void { + if (stopped) return; + stopped = true; + if (timer !== null) clearInterval(timer); + timer = null; + } + + async function poll(): Promise { + // A read slower than the interval must not stack up behind itself. + if (stopped || reading) return; + reading = true; + let bytes: Uint8Array | null = null; + try { + const file = await handle.getFile(); + if (file.lastModified > lastSeen) { + lastSeen = file.lastModified; + bytes = new Uint8Array(await file.arrayBuffer()); + } + } catch (error) { + // A file that has gone away never comes back; stop rather than spin. + stop(); + options.onError?.(error); + return; + } finally { + reading = false; + } + if (bytes && !stopped) options.onChange(bytes); + } + + timer = setInterval(() => { + void poll(); + }, every); + return { fileName, stop }; +} + +function isAbort(error: unknown): boolean { + return ( + typeof error === "object" && + error !== null && + (error as { name?: string }).name === "AbortError" + ); +} + +const PNG_SIGNATURE = new Uint8Array([ + 0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, +]); + +/** 8-bit RGBA PNG, no filtering - the file is a scratch pad for an editor. */ +export async function encodeRgbaAsPng( + pixels: ExternalEditPixels, +): Promise { + const { width, height } = pixels; + const rowBytes = width * 4; + const raw = new Uint8Array((rowBytes + 1) * height); + for (let y = 0; y < height; y++) { + raw[y * (rowBytes + 1)] = 0; + raw.set( + pixels.rgba.subarray(y * rowBytes, y * rowBytes + rowBytes), + y * (rowBytes + 1) + 1, + ); + } + const header = new Uint8Array(13); + const view = new DataView(header.buffer); + view.setUint32(0, width); + view.setUint32(4, height); + header[8] = 8; + header[9] = 6; + return concat([ + PNG_SIGNATURE, + pngChunk("IHDR", header), + pngChunk("IDAT", await zlibCompress(raw)), + pngChunk("IEND", new Uint8Array(0)), + ]); +} + +interface ByteTransform { + readable: { + getReader(): { read(): Promise<{ done: boolean; value?: Uint8Array }> }; + }; + writable: { + getWriter(): { + write(chunk: Uint8Array): Promise; + close(): Promise; + }; + }; +} + +interface CompressionHost { + CompressionStream?: new (format: string) => ByteTransform; +} + +async function zlibCompress(raw: Uint8Array): Promise { + const Ctor = (globalThis as unknown as CompressionHost).CompressionStream; + if (typeof Ctor !== "function") return zlibStored(raw); + try { + const stream = new Ctor("deflate"); + const writer = stream.writable.getWriter(); + // Not awaited before the read loop: a chunk larger than the queue would + // otherwise deadlock against a reader that has not started yet. + const written = writer + .write(raw) + .then(() => writer.close()) + .then( + () => true, + () => false, + ); + const reader = stream.readable.getReader(); + const parts: Uint8Array[] = []; + for (;;) { + const { done, value } = await reader.read(); + if (done) break; + if (value) parts.push(value); + } + return (await written) ? concat(parts) : zlibStored(raw); + } catch { + return zlibStored(raw); + } +} + +/** Valid zlib stream of uncompressed blocks; the fallback when no CompressionStream. */ +function zlibStored(raw: Uint8Array): Uint8Array { + const blockMax = 0xffff; + const blocks = Math.max(1, Math.ceil(raw.length / blockMax)); + const out = new Uint8Array(2 + blocks * 5 + raw.length + 4); + out[0] = 0x78; + out[1] = 0x01; + let p = 2; + for (let i = 0; i < blocks; i++) { + const start = i * blockMax; + const len = Math.min(blockMax, raw.length - start); + out[p++] = i === blocks - 1 ? 1 : 0; + out[p++] = len & 0xff; + out[p++] = (len >>> 8) & 0xff; + out[p++] = ~len & 0xff; + out[p++] = (~len >>> 8) & 0xff; + out.set(raw.subarray(start, start + len), p); + p += len; + } + const sum = adler32(raw); + out[p++] = (sum >>> 24) & 0xff; + out[p++] = (sum >>> 16) & 0xff; + out[p++] = (sum >>> 8) & 0xff; + out[p] = sum & 0xff; + return out; +} + +function pngChunk(type: string, body: Uint8Array): Uint8Array { + const out = new Uint8Array(body.length + 12); + const view = new DataView(out.buffer); + view.setUint32(0, body.length); + for (let i = 0; i < 4; i++) out[4 + i] = type.charCodeAt(i); + out.set(body, 8); + view.setUint32(out.length - 4, crc32(out.subarray(4, out.length - 4))); + return out; +} + +let crcTable: Uint32Array | null = null; + +function crc32(bytes: Uint8Array): number { + if (!crcTable) { + crcTable = new Uint32Array(256); + for (let n = 0; n < 256; n++) { + let c = n; + for (let k = 0; k < 8; k++) c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1; + crcTable[n] = c >>> 0; + } + } + let crc = 0xffffffff; + for (let i = 0; i < bytes.length; i++) { + crc = crcTable[(crc ^ bytes[i]) & 0xff] ^ (crc >>> 8); + } + return (crc ^ 0xffffffff) >>> 0; +} + +function adler32(bytes: Uint8Array): number { + let a = 1; + let b = 0; + for (let i = 0; i < bytes.length; i++) { + a = (a + bytes[i]) % 65521; + b = (b + a) % 65521; + } + return ((b << 16) | a) >>> 0; +} + +function concat(parts: Uint8Array[]): Uint8Array { + const total = parts.reduce((n, p) => n + p.length, 0); + const out = new Uint8Array(total); + let at = 0; + for (const part of parts) { + out.set(part, at); + at += part.length; + } + return out; +} diff --git a/frontend/editor/src/core/tools/pdfTextEditor/util/fallbackFont.ts b/frontend/editor/src/core/tools/pdfTextEditor/util/fallbackFont.ts new file mode 100644 index 0000000000..46055622e2 --- /dev/null +++ b/frontend/editor/src/core/tools/pdfTextEditor/util/fallbackFont.ts @@ -0,0 +1,207 @@ +import type { EditorDocument } from "@app/tools/pdfTextEditor/model/EditorDocument"; +import type { Page } from "@app/tools/pdfTextEditor/model/Page"; +import type { RGBA } from "@app/tools/pdfTextEditor/types"; +import { FontRef } from "@app/tools/pdfTextEditor/model/FontRef"; +import { parseTrueTypeCmap } from "@app/tools/pdfTextEditor/charcode/CmapResolver"; +import { writeUtf16 } from "@app/services/pdfiumService"; +import { BASE_PATH } from "@app/constants/app"; + +/** Client-side Unicode fallback font. */ +// BASE_PATH-prefixed: a bare "/fonts/..." 404s on subpath deployments +// (context-path / RUN_SUBPATH installs), permanently disabling the fallback. +const FALLBACK_FONT_URL = `${BASE_PATH}/fonts/NotoSans-Regular.ttf`; +const FALLBACK_FONT_ID = "__unicode_fallback"; +// FPDF_FONT_TRUETYPE; the trailing `true` makes it a composite (CID) font so +// FPDFText_SetText can address Unicode code points beyond 255. +const FPDF_FONT_TRUETYPE = 2; + +let bytesPromise: Promise | null = null; +let cachedBytes: Uint8Array | null = null; +let fallbackCoverage: Map | null = null; + +// True if the fallback font has a glyph for every non-whitespace code point of +// `text`. +function fallbackFontCovers(text: string): boolean { + if (!fallbackCoverage && cachedBytes) { + fallbackCoverage = parseTrueTypeCmap(cachedBytes); + } + if (!fallbackCoverage) return true; + for (const ch of text) { + if (/\s/.test(ch)) continue; + const cp = ch.codePointAt(0)!; + if (!fallbackCoverage.has(cp)) return false; + } + return true; +} + +interface ExtendedPdfiumRuntime { + HEAPU8: Uint8Array; +} + +/** Fetch the bundled fallback TTF once. Safe to call repeatedly. */ +export function preloadFallbackFontBytes(): Promise { + if (bytesPromise) return bytesPromise; + bytesPromise = (async () => { + try { + const res = await fetch(FALLBACK_FONT_URL); + if (!res.ok) { + // Don't cache the failure: a transient 404/503 would otherwise + // disable the Unicode fallback for the whole session. + bytesPromise = null; + return null; + } + cachedBytes = new Uint8Array(await res.arrayBuffer()); + return cachedBytes; + } catch { + bytesPromise = null; + return null; + } + })(); + return bytesPromise; +} + +/** Test/debug hook: bytes are loaded and a fallback emit is possible. */ +export function isFallbackFontReady(): boolean { + return !!cachedBytes && cachedBytes.length > 0; +} + +// Embed the Unicode fallback font into `doc` (once) and return its FPDF font +// handle, or 0 when the bytes aren't ready or the load failed. +export function loadFallbackFontInto(doc: EditorDocument): number { + const existing = doc.ownedFont(FALLBACK_FONT_ID); + if (existing) return existing.pointer; + // Idempotent - makes sure later edits find the bytes ready even if the + // first non-Latin edit raced the fetch. + void preloadFallbackFontBytes(); + const bytes = cachedBytes; + if (!bytes || bytes.length === 0) return 0; + + const m = doc.module; + const len = bytes.length; + const ptr = m.pdfium.wasmExports.malloc(len); + if (!ptr) return 0; + try { + (m.pdfium as typeof m.pdfium & ExtendedPdfiumRuntime).HEAPU8.set( + bytes, + ptr, + ); + const fontPtr = m.FPDFText_LoadFont( + doc.docPtr, + ptr, + len, + FPDF_FONT_TRUETYPE, + true, + ); + if (!fontPtr) { + m.pdfium.wasmExports.free(ptr); + return 0; + } + doc.registerOwnedFont( + new FontRef({ + id: FALLBACK_FONT_ID, + descriptor: { + id: FALLBACK_FONT_ID, + family: "Noto Sans", + style: "normal", + weight: "normal", + bundled: true, + }, + pointer: fontPtr, + owned: true, + // Free BOTH the font handle and its backing buffer on doc dispose. + closeFn: (p) => { + try { + m.FPDFFont_Close(p); + } catch { + /* best-effort */ + } + try { + m.pdfium.wasmExports.free(ptr); + } catch { + /* best-effort */ + } + }, + }), + ); + return fontPtr; + } catch { + try { + m.pdfium.wasmExports.free(ptr); + } catch { + /* best-effort */ + } + return 0; + } +} + +/** Right edge (PDF points) of an object's visible bbox, or 0 if unmeasurable. */ +function measureRightEdge(m: EditorDocument["module"], ptr: number): number { + const l = m.pdfium.wasmExports.malloc(4); + const b = m.pdfium.wasmExports.malloc(4); + const r = m.pdfium.wasmExports.malloc(4); + const t = m.pdfium.wasmExports.malloc(4); + try { + if (!m.FPDFPageObj_GetBounds(ptr, l, b, r, t)) return 0; + return m.pdfium.getValue(r, "float"); + } finally { + m.pdfium.wasmExports.free(l); + m.pdfium.wasmExports.free(b); + m.pdfium.wasmExports.free(r); + m.pdfium.wasmExports.free(t); + } +} + +interface CreateTextObjModule { + FPDFPageObj_CreateTextObj?: ( + doc: number, + font: number, + size: number, + ) => number; +} + +// Emit ONE text object for `text` in the embedded Unicode fallback font, placed +// at (x, y) with `fill`, inserted into the page. +export function emitFallbackTextObject( + doc: EditorDocument, + page: Page, + text: string, + size: number, + fill: RGBA, + x: number, + y: number, +): number { + const fb = loadFallbackFontInto(doc); + if (!fb) return 0; + if (!fallbackFontCovers(text)) return 0; + const m = doc.module; + const create = (m as unknown as CreateTextObjModule) + .FPDFPageObj_CreateTextObj; + if (typeof create !== "function") return 0; + const fp = create(doc.docPtr, fb, size); + if (!fp) return 0; + const tp = writeUtf16(m, text); + try { + m.FPDFText_SetText(fp, tp); + } finally { + m.pdfium.wasmExports.free(tp); + } + m.FPDFPageObj_SetFillColor(fp, fill.r, fill.g, fill.b, fill.a); + m.FPDFPageObj_Transform(fp, 1, 0, 0, 1, x, y); + m.FPDFPage_InsertObject(page.pagePtr, fp); + const right = measureRightEdge(m, fp); + const visible = text.replace(/\s+/g, "").length; + if (visible > 0 && right - x < visible * size * 0.05) { + try { + m.FPDFPage_RemoveObject(page.pagePtr, fp); + } catch { + /* best-effort */ + } + try { + m.FPDFPageObj_Destroy(fp); + } catch { + /* best-effort */ + } + return 0; + } + return fp; +} diff --git a/frontend/editor/src/core/tools/pdfTextEditor/util/fitText.ts b/frontend/editor/src/core/tools/pdfTextEditor/util/fitText.ts new file mode 100644 index 0000000000..d5dff72d38 --- /dev/null +++ b/frontend/editor/src/core/tools/pdfTextEditor/util/fitText.ts @@ -0,0 +1,60 @@ +/** + * Fit browser-laid-out text to the width the PDF actually advances. + * + * A PDF's /Widths array overrides the face's own advances, so even with the + * identical font embedded the browser lays the same string out at a different + * width - measured at 11-15% out on real files. Whenever the overlay paints + * visible glyphs over the page bitmap, that difference is the misalignment + * the user sees. + */ + +export interface TextFit { + /** Px to add to letter-spacing; negative tightens. */ + letterSpacing: number; + /** Horizontal scale, 1 when tracking alone closed the gap. */ + scaleX: number; +} + +export const NO_FIT: TextFit = { letterSpacing: 0, scaleX: 1 }; + +// Beyond this per-gap adjustment tracking stops reading as tracking and starts +// looking like a different font, so hand over to a scale instead. +const MAX_TRACK_EM = 0.12; +// A ratio outside this band means the inputs disagree about what is being +// measured (wrong line, stale bounds); leave the text alone rather than +// squash it into nonsense. +const MIN_SCALE = 0.5; +const MAX_SCALE = 2; + +/** + * Prefer tracking over scaling: condensing glyphs changes their stroke weight, + * so a scaled word reads bolder than its neighbours, while tight tracking is + * close to invisible. + */ +export function fitTextToWidth( + text: string, + measuredPx: number, + targetPx: number, + fontSizePx: number, +): TextFit { + if (!text || !Number.isFinite(measuredPx) || !Number.isFinite(targetPx)) { + return NO_FIT; + } + if (measuredPx <= 0 || targetPx <= 0 || fontSizePx <= 0) return NO_FIT; + + const overflow = measuredPx - targetPx; + // Sub-pixel differences are not worth a style that forces a re-layout. + if (Math.abs(overflow) <= 0.5) return NO_FIT; + + // Count code points: letter-spacing applies per character, and a surrogate + // pair is one character to the layout engine. + const count = [...text].length; + const perGap = overflow / count; + if (count > 1 && Math.abs(perGap) <= MAX_TRACK_EM * fontSizePx) { + return { letterSpacing: -perGap, scaleX: 1 }; + } + + const scale = targetPx / measuredPx; + if (scale < MIN_SCALE || scale > MAX_SCALE) return NO_FIT; + return { letterSpacing: 0, scaleX: scale }; +} diff --git a/frontend/editor/src/core/tools/pdfTextEditor/util/fontCapability.ts b/frontend/editor/src/core/tools/pdfTextEditor/util/fontCapability.ts new file mode 100644 index 0000000000..9566a823d0 --- /dev/null +++ b/frontend/editor/src/core/tools/pdfTextEditor/util/fontCapability.ts @@ -0,0 +1,152 @@ +import { + familyOf, + flipItalic, + isItalicFamily, +} from "@app/tools/pdfTextEditor/util/fontFamily"; +import { helveticaVariantFor } from "@app/tools/pdfTextEditor/util/helveticaVariant"; +import { + faceStyleFlags, + getLocalFontBytes, + loadLocalFontBytes, + loadedLocalFonts, + pickLocalFontFace, + splitRequested, + type LocalFont, +} from "@app/tools/pdfTextEditor/util/localFonts"; + +// Whether a style change is actually possible for a run's face, and in which +// family. The toolbar asks before offering the control: replacing a document's +// own typeface with Helvetica-Oblique is not "making it italic", it is losing +// the font. + +export type StyleSource = "base14" | "device"; + +export interface StyleCapability { + /** Family to emit, or null when nothing available can render the style. */ + family: string | null; + source: StyleSource | null; +} + +const NONE: StyleCapability = { family: null, source: null }; + +/** Families {@link warmDocumentDeviceFonts} has already looked up this session. */ +const attempted = new Set(); + +/** Test hook: forget which document families have been matched. */ +export function resetDocumentFontMatchCache(): void { + attempted.clear(); +} + +/** The installed face for `family` in the requested style, or null. */ +function deviceFaceFor( + fonts: LocalFont[], + family: string, + italic: boolean, +): string | null { + const req = splitRequested(family); + if (!req.family) return null; + const wanted = [req.family, req.bold ? "Bold" : "", italic ? "Italic" : ""] + .filter(Boolean) + .join(" "); + const face = pickLocalFontFace(fonts, wanted); + if (!face) return null; + // pickLocalFontFace always returns SOMETHING from a matching family, so the + // style has to be checked: a family with no italic cut answers with upright. + const flags = faceStyleFlags(face); + if (flags.italic !== italic) return null; + return wanted; +} + +/** + * Which family gives `fontId` its italic cut (or its upright one back). + * + * base-14 flips in place. Anything else - an embedded or subset face - needs a + * device font of the same family that genuinely carries the style, which only + * exists once the user has loaded their device fonts. + */ +export function italicCapability( + fontId: string, + italic: boolean, + fonts: LocalFont[] | null = loadedLocalFonts(), +): StyleCapability { + const family = familyOf(fontId); + if (!family) return NONE; + const flipped = flipItalic(family, italic); + if (flipped) return { family: flipped, source: "base14" }; + if (!fonts || fonts.length === 0) return NONE; + const device = deviceFaceFor(fonts, family, italic); + return device ? { family: device, source: "device" } : NONE; +} + +/** Whether every one of these runs can be flipped to the other italic state. */ +export function canToggleItalic( + fontIds: string[], + fonts: LocalFont[] | null = loadedLocalFonts(), +): boolean { + if (fontIds.length === 0) return false; + // Deduped: each miss costs a linear scan of every installed face, and a + // select-all hands this thousands of runs sharing a handful of fonts - on + // every keystroke, because the toolbar state is derived from the snapshot. + return [...new Set(fontIds)].every( + (id) => italicCapability(id, !isItalicFamily(id), fonts).family !== null, + ); +} + +/** + * The family an edited run re-emits in once its own font cannot author the + * glyph the user typed. + * + * A subset-embedded face only carries the characters the original document + * used, so typing a new letter drops out of the reuse path. Mapping the run + * straight to Helvetica there costs the document its typeface for the sake of + * one character; when the real family is installed and loaded, completing the + * subset from the device font keeps it. + */ +export function fallbackFamilyFor(fontId: string): string { + const family = familyOf(fontId); + // Readiness IS the opt-in: bytes only exist for a family the user has loaded + // their device fonts for. + if (family && getLocalFontBytes(family)) return family; + return helveticaVariantFor(fontId); +} + +/** + * The font id a run takes on once it re-emits in `family`. + * + * Tagging a device family `base14:` is what made the NEXT edit forget it - the + * prefix is how {@link fallbackFamilyFor} recognises a face worth keeping. + */ +export function fallbackFontIdFor(family: string): string { + return `${getLocalFontBytes(family) ? "device" : "base14"}:${family}`; +} + +/** + * Read the installed faces matching the DOCUMENT's own families, so a later + * edit that outgrows a subset has real bytes to complete it from. + * + * Only exact family matches are loaded - "Calibri" never warms "Calibri Light" + * - so recognition stays a match, not a guess. Returns the families matched. + */ +export async function warmDocumentDeviceFonts( + fontIds: Iterable, +): Promise { + const fonts = loadedLocalFonts(); + if (!fonts || fonts.length === 0) return []; + const wanted = new Set(); + for (const id of fontIds) { + const family = familyOf(id); + // base-14 renders everywhere already; nothing to complete. + if (!family || flipItalic(family, false)) continue; + // Every edit re-runs this over the whole page model, and scanning a few + // thousand installed faces per keystroke is not free. + if (attempted.has(family)) continue; + attempted.add(family); + if (getLocalFontBytes(family)) continue; + if (pickLocalFontFace(fonts, family)) wanted.add(family); + } + const matched: string[] = []; + for (const family of wanted) { + if (await loadLocalFontBytes(family)) matched.push(family); + } + return matched; +} diff --git a/frontend/editor/src/core/tools/pdfTextEditor/util/fontFamily.ts b/frontend/editor/src/core/tools/pdfTextEditor/util/fontFamily.ts new file mode 100644 index 0000000000..4fbaf7a626 --- /dev/null +++ b/frontend/editor/src/core/tools/pdfTextEditor/util/fontFamily.ts @@ -0,0 +1,98 @@ +// Helpers for inspecting and flipping the bold/italic variants of the PDF +// base-14 font families used by the toolbar. + +export function isBoldFamily(fontId: string): boolean { + return /bold/i.test(fontId); +} + +export function isItalicFamily(fontId: string): boolean { + return /italic|oblique/i.test(fontId); +} + +/** Strip any `prefix:` qualifier that `PdfiumTextReader` adds to font ids. */ +export function familyOf(fontId: string): string { + const idx = fontId.lastIndexOf(":"); + return idx >= 0 ? fontId.slice(idx + 1) : fontId; +} + +type Base14Root = "Helvetica" | "Times" | "Courier"; + +/** Which base-14 family a name belongs to, or null if it isn't base-14. */ +function base14Root(family: string): Base14Root | null { + if (/^Helvetica/i.test(family)) return "Helvetica"; + if (/^Times/i.test(family)) return "Times"; + if (/^Courier/i.test(family)) return "Courier"; + return null; +} + +/** Build the EXACT base-14 PostScript name for a root + bold/italic combo. */ +function base14Name(root: Base14Root, bold: boolean, italic: boolean): string { + if (root === "Times") { + if (bold && italic) return "Times-BoldItalic"; + if (bold) return "Times-Bold"; + if (italic) return "Times-Italic"; + return "Times-Roman"; + } + // Helvetica + Courier share the Oblique spelling. + if (bold && italic) return `${root}-BoldOblique`; + if (bold) return `${root}-Bold`; + if (italic) return `${root}-Oblique`; + return root; +} + +/** The Helvetica variant for a bold/italic combo. */ +export function helveticaWith(bold: boolean, italic: boolean): string { + return base14Name("Helvetica", bold, italic); +} + +// Map a base-14 family to its bold variant (or back), preserving the current +// italic/oblique state. +export function flipBold(currentFamily: string, on: boolean): string | null { + const root = base14Root(currentFamily); + if (!root) return null; + return base14Name(root, on, isItalicFamily(currentFamily)); +} + +// Map a base-14 family to its italic/oblique variant (or back), preserving the +// current bold state. +export function flipItalic(currentFamily: string, on: boolean): string | null { + const root = base14Root(currentFamily); + if (!root) return null; + return base14Name(root, isBoldFamily(currentFamily), on); +} + +/** Exact names PDFium will build a text object for. */ +const STANDARD_FONTS = new Set([ + "Helvetica", + "Helvetica-Bold", + "Helvetica-Oblique", + "Helvetica-BoldOblique", + "Times-Roman", + "Times-Bold", + "Times-Italic", + "Times-BoldItalic", + "Courier", + "Courier-Bold", + "Courier-Oblique", + "Courier-BoldOblique", + "Symbol", + "ZapfDingbats", +]); + +// The standard PDF font that best stands in for an arbitrary family: PDFium +// can only build a text object for one of the 14, so approximate, don't drop. +export function nearestStandardFont(family: string): string { + if (STANDARD_FONTS.has(family)) return family; + const name = family.toLowerCase(); + const bold = /bold|black|heavy|semibold|demi/.test(name); + const italic = /italic|oblique/.test(name); + if (/mono|courier|consol|menlo|code/.test(name)) { + return base14Name("Courier", bold, italic); + } + if ( + /serif|times|georgia|garamond|book|roman|minion|cambria|palatino/.test(name) + ) { + return base14Name("Times", bold, italic); + } + return base14Name("Helvetica", bold, italic); +} diff --git a/frontend/editor/src/core/tools/pdfTextEditor/util/guides.ts b/frontend/editor/src/core/tools/pdfTextEditor/util/guides.ts new file mode 100644 index 0000000000..42f33a5bc7 --- /dev/null +++ b/frontend/editor/src/core/tools/pdfTextEditor/util/guides.ts @@ -0,0 +1,283 @@ +// Ruler ticks and per-page guides. Pure and DOM-free so the geometry is +// testable; positions are RAW PDF points, so they survive crop and rotation. + +export type GuideAxis = "x" | "y"; + +/** A guide before it has been assigned an id by the store. */ +export interface GuideSeed { + axis: GuideAxis; + /** Raw PDF page-space coordinate (points) the guide holds constant. */ + position: number; +} + +export interface Guide extends GuideSeed { + id: string; +} + +export interface GuideSnap { + value: number; + guide: Guide | null; +} + +/** Orientation of a guide once drawn on the rendered (rotated) page. */ +export type GuideOrientation = "vertical" | "horizontal"; + +export interface GuideLine { + orientation: GuideOrientation; + /** Display-PDF coordinate (points, y-up, origin at the page's lower-left). */ + position: number; +} + +/** Structural slice of `DisplayTransform`, so this module stays model-free. */ +export interface GuideTransform { + apply(x: number, y: number): { x: number; y: number }; + invert(x: number, y: number): { x: number; y: number }; +} + +/** Smallest on-screen gap between neighbouring ticks, in CSS pixels. */ +export const MIN_TICK_SPACING_PX = 6; +/** Smallest on-screen gap between labelled (major) ticks, in CSS pixels. */ +export const MIN_LABEL_SPACING_PX = 48; + +const AXIS_EPSILON = 1e-6; +const MULTIPLE_EPSILON = 1e-6; +/** Upper bound on ticks per ruler; a huge page at huge zoom widens the step. */ +const MAX_TICKS = 4000; +const STEP_LADDER = buildStepLadder(); + +export interface RulerTick { + /** Offset along the ruler from the page origin, in PDF points. */ + position: number; + major: boolean; + /** Set only on major ticks. */ + label: string | null; +} + +export interface RulerScale { + minorStep: number; + majorStep: number; + ticks: RulerTick[]; +} + +// Ruler ticks at `scale` CSS px per point. The interval climbs a 1/2/5 ladder +// so ticks and labels never crowd below their minimum spacing. +export function rulerTicks(lengthInPoints: number, scale: number): RulerScale { + if ( + !Number.isFinite(lengthInPoints) || + !Number.isFinite(scale) || + lengthInPoints <= 0 || + scale <= 0 + ) { + return { minorStep: 0, majorStep: 0, ticks: [] }; + } + // Floor the step by the tick budget too, so an extreme zoom widens the + // interval instead of truncating the ruler part-way down the page. + const budget = lengthInPoints / MAX_TICKS; + const minorStep = pickStep(scale, MIN_TICK_SPACING_PX, 0, budget); + const majorStep = pickStep(scale, MIN_LABEL_SPACING_PX, minorStep, budget); + const decimals = labelDecimals(majorStep); + const last = Math.floor(lengthInPoints / minorStep + MULTIPLE_EPSILON); + const ticks: RulerTick[] = []; + for (let i = 0; i <= last; i += 1) { + const position = roundStep(i * minorStep); + const major = isMultipleOf(position, majorStep); + ticks.push({ + position, + major, + label: major ? formatTickLabel(position, decimals) : null, + }); + } + return { minorStep, majorStep, ticks }; +} + +// Snap to the nearest guide within tolerance; ties take the lower id so a drag +// hovering exactly between two guides never flickers. +export function snapToGuides( + value: number, + guides: readonly Guide[], + toleranceInPoints: number, +): GuideSnap { + if ( + !Number.isFinite(value) || + !Number.isFinite(toleranceInPoints) || + toleranceInPoints < 0 + ) { + return { value, guide: null }; + } + let best: Guide | null = null; + let bestDistance = Number.POSITIVE_INFINITY; + for (const guide of guides) { + if (!Number.isFinite(guide.position)) continue; + const distance = Math.abs(guide.position - value); + if (distance > toleranceInPoints) continue; + if ( + distance < bestDistance || + (distance === bestDistance && best !== null && guide.id < best.id) + ) { + best = guide; + bestDistance = distance; + } + } + return best ? { value: best.position, guide: best } : { value, guide: null }; +} + +/** Where a raw-PDF guide lands on the rendered (cropped/rotated) page. */ +export function guideToLine( + guide: GuideSeed, + transform: GuideTransform, +): GuideLine { + const a = + guide.axis === "x" + ? transform.apply(guide.position, 0) + : transform.apply(0, guide.position); + const b = + guide.axis === "x" + ? transform.apply(guide.position, 1) + : transform.apply(1, guide.position); + // The linear part is a quarter-turn rotation, so exactly one display + // coordinate stays constant along the line; that one names the orientation. + return Math.abs(a.x - b.x) <= AXIS_EPSILON + ? { orientation: "vertical", position: a.x } + : { orientation: "horizontal", position: a.y }; +} + +/** Inverse of `guideToLine`: the raw-PDF guide a drawn line represents. */ +export function lineToGuide( + line: GuideLine, + transform: GuideTransform, +): GuideSeed { + const a = + line.orientation === "vertical" + ? transform.invert(line.position, 0) + : transform.invert(0, line.position); + const b = + line.orientation === "vertical" + ? transform.invert(line.position, 1) + : transform.invert(1, line.position); + return Math.abs(a.x - b.x) <= AXIS_EPSILON + ? { axis: "x", position: a.x } + : { axis: "y", position: a.y }; +} + +const NO_GUIDES: Guide[] = []; + +type GuideListener = (pageIndex: number, guides: Guide[]) => void; + +// Per-page guide state with a subscribe channel, shaped like `Selection`. +// Arrays are replaced, never mutated, so subscribers can compare identities. +export class GuideStore { + private byPage: Map = new Map(); + private listeners: Set = new Set(); + private counter = 0; + + get(pageIndex: number): Guide[] { + return this.byPage.get(pageIndex) ?? NO_GUIDES; + } + + add(pageIndex: number, axis: GuideAxis, position: number): Guide | null { + if (!Number.isFinite(position)) return null; + this.counter += 1; + // Zero-padded so lexicographic id order matches creation order, which is + // what `snapToGuides` leans on for its tie-break. + const id = `guide-${String(this.counter).padStart(6, "0")}`; + const guide: Guide = { id, axis, position }; + this.byPage.set(pageIndex, [...this.get(pageIndex), guide]); + this.notify(pageIndex); + return guide; + } + + move(pageIndex: number, id: string, position: number): void { + if (!Number.isFinite(position)) return; + const current = this.get(pageIndex); + const index = current.findIndex((g) => g.id === id); + if (index < 0 || current[index].position === position) return; + const next = current.slice(); + next[index] = { ...current[index], position }; + this.byPage.set(pageIndex, next); + this.notify(pageIndex); + } + + remove(pageIndex: number, id: string): void { + const current = this.get(pageIndex); + const next = current.filter((g) => g.id !== id); + if (next.length === current.length) return; + this.byPage.set(pageIndex, next); + this.notify(pageIndex); + } + + clear(pageIndex?: number): void { + if (pageIndex === undefined) { + const pages = Array.from(this.byPage.keys()); + this.byPage.clear(); + for (const page of pages) this.notify(page); + return; + } + if (this.get(pageIndex).length === 0) return; + this.byPage.delete(pageIndex); + this.notify(pageIndex); + } + + subscribe(listener: GuideListener): () => void { + this.listeners.add(listener); + return () => this.listeners.delete(listener); + } + + private notify(pageIndex: number): void { + // Snapshot + guard: a subscriber may synchronously unsubscribe others + // or throw; iterating the live Set would skip listeners or abort early. + for (const listener of Array.from(this.listeners)) { + try { + listener(pageIndex, this.get(pageIndex)); + } catch { + /* one listener throwing must not stop the rest */ + } + } + } +} + +/** Shared guide state for the open document; `clear()` on document swap. */ +export const pageGuides = new GuideStore(); + +function buildStepLadder(): number[] { + const steps: number[] = []; + for (let exponent = -3; exponent <= 6; exponent += 1) { + for (const mantissa of [1, 2, 5]) { + steps.push(roundStep(mantissa * Math.pow(10, exponent))); + } + } + return steps; +} + +function pickStep( + scale: number, + minPx: number, + multipleOf: number, + minStep: number, +): number { + for (const step of STEP_LADDER) { + if (step < minStep || step * scale < minPx) continue; + if (multipleOf > 0 && !isMultipleOf(step, multipleOf)) continue; + return step; + } + return STEP_LADDER[STEP_LADDER.length - 1]; +} + +function isMultipleOf(value: number, step: number): boolean { + if (step <= 0) return false; + const ratio = value / step; + return Math.abs(ratio - Math.round(ratio)) < MULTIPLE_EPSILON; +} + +/** Trim the float noise from `mantissa * 10^e` so ticks compare exactly. */ +function roundStep(value: number): number { + return Number(value.toPrecision(12)); +} + +function labelDecimals(step: number): number { + if (step <= 0) return 0; + return Math.max(0, Math.min(6, Math.ceil(-Math.log10(step)))); +} + +function formatTickLabel(value: number, decimals: number): string { + return decimals > 0 ? value.toFixed(decimals) : String(Math.round(value)); +} diff --git a/frontend/editor/src/core/tools/pdfTextEditor/util/helveticaVariant.ts b/frontend/editor/src/core/tools/pdfTextEditor/util/helveticaVariant.ts new file mode 100644 index 0000000000..b158e714c1 --- /dev/null +++ b/frontend/editor/src/core/tools/pdfTextEditor/util/helveticaVariant.ts @@ -0,0 +1,36 @@ +const DEVICE_FONT_PREFIX = "device:"; + +// Map a source font id to the base-14 family + style that best preserves its +// broad class. +export function helveticaVariantFor(fontId: string): string { + // A run already carrying an embedded device font keeps it; mapping to + // base-14 here is what reverted "Segoe UI" to Helvetica on the next edit. + if (fontId.startsWith(DEVICE_FONT_PREFIX)) { + return fontId.slice(DEVICE_FONT_PREFIX.length); + } + const bold = /bold|black|heavy/i.test(fontId); + const italic = /italic|oblique/i.test(fontId); + const mono = /mono|courier|consol/i.test(fontId); + // "roman"/"cmr"/"lmroman" cover LaTeX Computer Modern serif families. + const serif = + !mono && + /times|serif|roman|georgia|garamond|minion|palatino|cambria|book\s?antiqua|(^|[^a-z])(cmr|lmroman|lmr)/i.test( + fontId, + ); + if (mono) { + if (bold && italic) return "Courier-BoldOblique"; + if (bold) return "Courier-Bold"; + if (italic) return "Courier-Oblique"; + return "Courier"; + } + if (serif) { + if (bold && italic) return "Times-BoldItalic"; + if (bold) return "Times-Bold"; + if (italic) return "Times-Italic"; + return "Times-Roman"; + } + if (bold && italic) return "Helvetica-BoldOblique"; + if (bold) return "Helvetica-Bold"; + if (italic) return "Helvetica-Oblique"; + return "Helvetica"; +} diff --git a/frontend/editor/src/core/tools/pdfTextEditor/util/imagePicking.ts b/frontend/editor/src/core/tools/pdfTextEditor/util/imagePicking.ts new file mode 100644 index 0000000000..5c2f65b709 --- /dev/null +++ b/frontend/editor/src/core/tools/pdfTextEditor/util/imagePicking.ts @@ -0,0 +1,83 @@ +// Picking and decoding a replacement image. The toolbar renders in the +// workbench, a different React tree from the panel owning the file inputs. +import type { DecodedImage } from "@app/utils/pdfiumBitmapUtils"; + +export interface PickedImage { + decoded: DecodedImage; + /** Present for JPEGs, so the embed can pass the original bytes through. */ + jpegBytes?: Uint8Array; +} + +export function pickImageFile(): Promise { + return new Promise((resolve) => { + const input = document.createElement("input"); + input.type = "file"; + input.accept = "image/*"; + input.style.display = "none"; + let settled = false; + const done = (file: File | null): void => { + if (settled) return; + settled = true; + input.remove(); + resolve(file); + }; + input.addEventListener("change", () => done(input.files?.[0] ?? null)); + // No cancel event fires in older browsers, so the dialog closing without + // a pick simply leaves the promise pending until the next focus. + input.addEventListener("cancel", () => done(null)); + document.body.appendChild(input); + input.click(); + }); +} + +export async function decodeImageForEmbed(file: File): Promise { + const decoded = await decodeToRgba(file); + if (file.type === "image/jpeg") { + return { + decoded, + jpegBytes: new Uint8Array(await file.arrayBuffer()), + }; + } + return { decoded }; +} + +/** Decode PNG bytes that came back from an external editor. */ +export async function decodeBytesForEmbed( + bytes: Uint8Array, + type = "image/png", +): Promise { + return decodeToRgba(new File([bytes as BlobPart], "external", { type })); +} + +function decodeToRgba(file: File): Promise { + return new Promise((resolve, reject) => { + const url = URL.createObjectURL(file); + const img = new Image(); + img.onload = () => { + try { + const width = img.naturalWidth || img.width; + const height = img.naturalHeight || img.height; + const canvas = document.createElement("canvas"); + canvas.width = width; + canvas.height = height; + const ctx = canvas.getContext("2d"); + if (!ctx) { + reject(new Error("Canvas 2D context unavailable")); + return; + } + ctx.drawImage(img, 0, 0); + const data = ctx.getImageData(0, 0, width, height); + resolve({ rgba: new Uint8Array(data.data.buffer), width, height }); + } catch (e) { + reject(e instanceof Error ? e : new Error(String(e))); + } finally { + URL.revokeObjectURL(url); + } + }; + img.onerror = () => { + URL.revokeObjectURL(url); + reject(new Error("Could not decode the selected image.")); + }; + img.src = url; + }); +} diff --git a/frontend/editor/src/core/tools/pdfTextEditor/util/imagePixels.ts b/frontend/editor/src/core/tools/pdfTextEditor/util/imagePixels.ts new file mode 100644 index 0000000000..8989e98f18 --- /dev/null +++ b/frontend/editor/src/core/tools/pdfTextEditor/util/imagePixels.ts @@ -0,0 +1,104 @@ +// Read an image object's pixels back out of PDFium: the round trip must hand +// over the picture as it stands now, not the file it originally came from. +import type { WrappedPdfiumModule } from "@embedpdf/pdfium"; +import type { EditorDocument } from "@app/tools/pdfTextEditor/model/EditorDocument"; + +export interface ImagePixels { + rgba: Uint8Array; + width: number; + height: number; +} + +interface ImageBitmapModule { + FPDFImageObj_GetBitmap?: (obj: number) => number; + FPDFImageObj_GetRenderedBitmap?: ( + doc: number, + page: number, + obj: number, + ) => number; + FPDFBitmap_GetBuffer?: (bitmap: number) => number; + FPDFBitmap_GetWidth?: (bitmap: number) => number; + FPDFBitmap_GetHeight?: (bitmap: number) => number; + FPDFBitmap_GetStride?: (bitmap: number) => number; + FPDFBitmap_GetFormat?: (bitmap: number) => number; + FPDFBitmap_Destroy?: (bitmap: number) => void; +} + +/** FPDFBitmap_* format ids. */ +const FORMAT_GRAY = 1; +const FORMAT_BGR = 2; +const FORMAT_BGRA = 4; + +export function readImageObjectPixels( + doc: EditorDocument, + pageIndex: number, + objPtr: number, +): ImagePixels | null { + if (!objPtr) return null; + const m = doc.module; + const mod = m as unknown as ImageBitmapModule; + const page = doc.page(pageIndex); + // Any pending edit has to be in the content stream before PDFium will + // rasterise the object as the user currently sees it. + page.flushGenerate(m); + + let bitmap = 0; + try { + bitmap = + mod.FPDFImageObj_GetRenderedBitmap?.(doc.docPtr, page.pagePtr, objPtr) ?? + 0; + if (!bitmap) bitmap = mod.FPDFImageObj_GetBitmap?.(objPtr) ?? 0; + if (!bitmap) return null; + + const width = mod.FPDFBitmap_GetWidth?.(bitmap) ?? 0; + const height = mod.FPDFBitmap_GetHeight?.(bitmap) ?? 0; + const stride = mod.FPDFBitmap_GetStride?.(bitmap) ?? 0; + const buffer = mod.FPDFBitmap_GetBuffer?.(bitmap) ?? 0; + const format = mod.FPDFBitmap_GetFormat?.(bitmap) ?? FORMAT_BGRA; + if (width <= 0 || height <= 0 || stride <= 0 || !buffer) return null; + + const heap = heapView(m); + const bytesPerPixel = + format === FORMAT_GRAY ? 1 : format === FORMAT_BGR ? 3 : 4; + const rgba = new Uint8Array(width * height * 4); + for (let y = 0; y < height; y += 1) { + let src = buffer + y * stride; + let dst = y * width * 4; + for (let x = 0; x < width; x += 1) { + // PDFium hands back gray or BGR(A); the canvas/PNG world wants RGBA. + if (format === FORMAT_GRAY) { + rgba[dst] = heap[src]; + rgba[dst + 1] = heap[src]; + rgba[dst + 2] = heap[src]; + rgba[dst + 3] = 255; + } else { + rgba[dst] = heap[src + 2]; + rgba[dst + 1] = heap[src + 1]; + rgba[dst + 2] = heap[src]; + rgba[dst + 3] = format === FORMAT_BGRA ? heap[src + 3] : 255; + } + src += bytesPerPixel; + dst += 4; + } + } + return { rgba, width, height }; + } catch { + return null; + } finally { + if (bitmap) { + try { + mod.FPDFBitmap_Destroy?.(bitmap); + } catch { + /* best-effort */ + } + } + } +} + +/** Re-acquired per call: growing the WASM memory detaches an older view. */ +function heapView(m: WrappedPdfiumModule): Uint8Array { + const memory = ( + m.pdfium.wasmExports as unknown as { memory: WebAssembly.Memory } + ).memory; + return new Uint8Array(memory.buffer); +} diff --git a/frontend/editor/src/core/tools/pdfTextEditor/util/jpegOrientation.ts b/frontend/editor/src/core/tools/pdfTextEditor/util/jpegOrientation.ts new file mode 100644 index 0000000000..aa8007e2de --- /dev/null +++ b/frontend/editor/src/core/tools/pdfTextEditor/util/jpegOrientation.ts @@ -0,0 +1,60 @@ +/** Read a JPEG's EXIF orientation (1-8); 1 when absent or unreadable. */ +export function jpegExifOrientation(bytes: Uint8Array): number { + if (bytes.length < 4 || bytes[0] !== 0xff || bytes[1] !== 0xd8) return 1; + let off = 2; + while (off + 4 <= bytes.length) { + if (bytes[off] !== 0xff) return 1; + const marker = bytes[off + 1]; + // SOS/EOI: image data begins - no EXIF ahead. + if (marker === 0xda || marker === 0xd9) return 1; + const size = (bytes[off + 2] << 8) | bytes[off + 3]; + if (size < 2) return 1; + if (marker === 0xe1 && size >= 10) { + const seg = off + 4; + const isExif = + bytes[seg] === 0x45 && // E + bytes[seg + 1] === 0x78 && // x + bytes[seg + 2] === 0x69 && // i + bytes[seg + 3] === 0x66 && // f + bytes[seg + 4] === 0 && + bytes[seg + 5] === 0; + if (isExif) { + const tiff = seg + 6; + const little = bytes[tiff] === 0x49 && bytes[tiff + 1] === 0x49; + const big = bytes[tiff] === 0x4d && bytes[tiff + 1] === 0x4d; + if (!little && !big) return 1; + const u16 = (p: number): number => + little + ? bytes[p] | (bytes[p + 1] << 8) + : (bytes[p] << 8) | bytes[p + 1]; + const u32 = (p: number): number => + little + ? (bytes[p] | + (bytes[p + 1] << 8) | + (bytes[p + 2] << 16) | + (bytes[p + 3] << 24)) >>> + 0 + : ((bytes[p] << 24) | + (bytes[p + 1] << 16) | + (bytes[p + 2] << 8) | + bytes[p + 3]) >>> + 0; + if (tiff + 8 > bytes.length) return 1; + const ifd = tiff + u32(tiff + 4); + if (ifd + 2 > bytes.length) return 1; + const count = u16(ifd); + for (let i = 0; i < count; i++) { + const e = ifd + 2 + i * 12; + if (e + 12 > bytes.length) return 1; + if (u16(e) === 0x0112) { + const v = u16(e + 8); + return v >= 1 && v <= 8 ? v : 1; + } + } + return 1; + } + } + off += 2 + size; + } + return 1; +} diff --git a/frontend/editor/src/core/tools/pdfTextEditor/util/lineLayout.ts b/frontend/editor/src/core/tools/pdfTextEditor/util/lineLayout.ts new file mode 100644 index 0000000000..0422146269 --- /dev/null +++ b/frontend/editor/src/core/tools/pdfTextEditor/util/lineLayout.ts @@ -0,0 +1,60 @@ +export interface TokenFit { + letterSpacingPx: number; + marginRightPx: number; +} + +export const NO_TOKEN_FIT: TokenFit = { + letterSpacingPx: 0, + marginRightPx: 0, +}; + +const MAX_TRACK_EM = 0.25; +const EPSILON_PX = 0.01; + +export function fitTokenAdvance( + charCount: number, + naturalPx: number, + targetPx: number, + fontSizePx: number, +): TokenFit { + if (charCount <= 0) return NO_TOKEN_FIT; + if (!Number.isFinite(naturalPx) || !Number.isFinite(targetPx)) { + return NO_TOKEN_FIT; + } + if (naturalPx < 0 || targetPx < 0) return NO_TOKEN_FIT; + + const delta = targetPx - naturalPx; + if (Math.abs(delta) < EPSILON_PX) return NO_TOKEN_FIT; + + let letterSpacingPx = 0; + if (charCount > 1) { + const cap = MAX_TRACK_EM * Math.max(0, fontSizePx); + const even = delta / (charCount - 1); + letterSpacingPx = Math.max(-cap, Math.min(cap, even)); + } + return { + letterSpacingPx, + marginRightPx: delta - charCount * letterSpacingPx, + }; +} + +export interface LineStack { + topPx: number; + marginTopsPx: number[]; +} + +export function stackLineBoxes( + baselineTopsPx: number[], + lineHeightPx: number, + baselineFromBoxTopPx: number, +): LineStack | null { + if (baselineTopsPx.length === 0) return null; + if (!Number.isFinite(lineHeightPx) || lineHeightPx <= 0) return null; + if (!Number.isFinite(baselineFromBoxTopPx)) return null; + if (!baselineTopsPx.every((v) => Number.isFinite(v))) return null; + + const marginTopsPx = baselineTopsPx.map((top, i) => + i === 0 ? 0 : top - baselineTopsPx[i - 1] - lineHeightPx, + ); + return { topPx: baselineTopsPx[0] - baselineFromBoxTopPx, marginTopsPx }; +} diff --git a/frontend/editor/src/core/tools/pdfTextEditor/util/localFonts.ts b/frontend/editor/src/core/tools/pdfTextEditor/util/localFonts.ts new file mode 100644 index 0000000000..1906900af2 --- /dev/null +++ b/frontend/editor/src/core/tools/pdfTextEditor/util/localFonts.ts @@ -0,0 +1,307 @@ +// Local Font Access API wrapper. Chromium-only and permission-gated, so every +// entry point degrades to null instead of throwing. + +export interface LocalFont { + family: string; + fullName: string; + style: string; + postscriptName: string; +} + +export interface LocalFontFamily { + family: string; + styles: string[]; +} + +type QueryLocalFonts = () => Promise; + +function localFontQuery(): QueryLocalFonts | null { + if (typeof window === "undefined") return null; + const w = window as unknown as { queryLocalFonts?: QueryLocalFonts }; + if (typeof w.queryLocalFonts !== "function") return null; + // Bound: Chrome throws "Illegal invocation" when the method is detached. + return w.queryLocalFonts.bind(w); +} + +/** Feature detection only - never prompts and has no side effects. */ +export function isLocalFontAccessSupported(): boolean { + return localFontQuery() !== null; +} + +function readString(value: unknown): string { + return typeof value === "string" ? value.trim() : ""; +} + +function toLocalFont(face: unknown): LocalFont | null { + if (!face || typeof face !== "object") return null; + const data = face as Record; + const family = readString(data.family); + if (!family) return null; + return { + family, + fullName: readString(data.fullName) || family, + style: readString(data.style), + postscriptName: readString(data.postscriptName), + }; +} + +// The raw `FontData` each mapped face came from: only it exposes `.blob()`, +// and `LocalFont` stays a plain data shape. +interface FaceEntry { + font: LocalFont; + source: unknown; +} + +let faceEntries: FaceEntry[] = []; +let resolved: LocalFont[] | null = null; +const listeners = new Set<() => void>(); + +async function queryOnce(): Promise { + const query = localFontQuery(); + if (!query) return null; + try { + const faces = await query(); + if (!Array.isArray(faces)) return null; + const entries: FaceEntry[] = []; + for (const face of faces) { + const font = toLocalFont(face); + if (font) entries.push({ font, source: face }); + } + faceEntries = entries; + resolved = entries.map((entry) => entry.font); + for (const listener of [...listeners]) listener(); + return resolved; + } catch { + // SecurityError, NotAllowedError, a dismissed prompt and anything + // unexpected all mean the same thing to callers: no device fonts. + return null; + } +} + +let pending: Promise | null = null; + +/** The installed faces, or null. Memoised so the prompt fires at most once. */ +export async function listLocalFonts(): Promise { + if (!pending) pending = queryOnce(); + return pending; +} + +/** + * The faces {@link listLocalFonts} has already resolved, or null. + * + * Never prompts and never awaits, so render-time callers (a toolbar deciding + * whether italic is even possible) can read the list without granting + * themselves permission the user has not given. Reference-stable, so it is a + * valid `useSyncExternalStore` snapshot. + */ +export function loadedLocalFonts(): LocalFont[] | null { + return resolved; +} + +/** Fires once the device fonts resolve, so derived UI state can recompute. */ +export function subscribeLocalFonts(listener: () => void): () => void { + listeners.add(listener); + return () => listeners.delete(listener); +} + +/** Drops the memoised result. Exists for tests. */ +export function resetLocalFontsCache(): void { + pending = null; + faceEntries = []; + resolved = null; + bytesByFamily.clear(); + bytesPending.clear(); +} + +function compareNames(a: string, b: string): number { + return a.localeCompare(b, undefined, { sensitivity: "base" }); +} + +/** Collapse the face list into families with styles, sorted and de-duplicated. */ +export function groupByFamily(fonts: LocalFont[]): LocalFontFamily[] { + const byFamily = new Map(); + for (const font of fonts) { + if (!font.family) continue; + const key = font.family.toLowerCase(); + let entry = byFamily.get(key); + if (!entry) { + entry = { family: font.family, styles: [] }; + byFamily.set(key, entry); + } + const style = font.style; + if (!style) continue; + const seen = entry.styles.some( + (s) => s.toLowerCase() === style.toLowerCase(), + ); + if (!seen) entry.styles.push(style); + } + const families = [...byFamily.values()]; + for (const entry of families) entry.styles.sort(compareNames); + families.sort((a, b) => compareNames(a.family, b.family)); + return families; +} + +/** Case/separator-insensitive key, so "Segoe-UI" and "Segoe UI" are one name. */ +function normaliseName(name: string): string { + return name + .trim() + .toLowerCase() + .replace(/[\s_-]+/g, " "); +} + +export interface RequestedFace { + family: string; + bold: boolean; + italic: boolean; +} + +// Split a picker value into family plus style axes, so "Segoe UI Bold" finds +// "Segoe UI". A bare name yields the upright regular cut. +export function splitRequested(requested: string): RequestedFace { + const spaced = requested.trim().replace(/[_-]+/g, " "); + const bold = /\bbold\b/i.test(spaced); + const italic = /\b(italic|oblique)\b/i.test(spaced); + const family = spaced + .replace(/\b(bold|italic|oblique|regular|book|normal|roman)\b/gi, " ") + .replace(/\s+/g, " ") + .trim(); + return { family: family || spaced, bold, italic }; +} + +// The style words of a face. The family is excluded on purpose so a face of +// the family "Arial Black" is not read as a bold cut. +function faceStyleText(font: LocalFont): string { + if (font.style) return font.style.toLowerCase(); + const dash = font.postscriptName.indexOf("-"); + return dash >= 0 ? font.postscriptName.slice(dash + 1).toLowerCase() : ""; +} + +/** + * The style axes an installed face actually carries. + * + * Callers use it to tell "this family really has an italic cut" from + * "pickLocalFontFace returned the upright cut because there was nothing else". + */ +export function faceStyleFlags(font: LocalFont): { + bold: boolean; + italic: boolean; +} { + const style = faceStyleText(font); + return { + bold: /bold|black|heavy|semib|demi/.test(style), + // A family NAMED "Foo Italic" carries the axis even if its style says + // "Regular", which is how several shipped fonts describe themselves. + italic: /italic|oblique/.test(`${style} ${font.family.toLowerCase()}`), + }; +} + +function scoreFace( + font: LocalFont, + wantBold: boolean, + wantItalic: boolean, +): number { + const style = faceStyleText(font); + const bold = /bold|black|heavy|semib|demi/.test(style); + const italic = /italic|oblique/.test(style); + let score = 0; + if (bold === wantBold) score += 4; + if (italic === wantItalic) score += 4; + if (/^(regular|book|normal|roman)?$/.test(style)) score += 2; + // Tie-break towards the plainer cut: "Light Condensed" also matches an + // upright non-bold request, but "Regular" is what the user meant. + return score - Math.min(style.length, 32) / 100; +} + +// The installed face best answering a family name, or null. An exact family +// hit wins, so "Arial Black" is not read as a bold cut of "Arial". +export function pickLocalFontFace( + fonts: LocalFont[], + requested: string, +): LocalFont | null { + const wanted = splitRequested(requested); + const exact = fonts.filter( + (font) => normaliseName(font.family) === normaliseName(requested), + ); + const group = + exact.length > 0 + ? exact + : fonts.filter( + (font) => normaliseName(font.family) === normaliseName(wanted.family), + ); + if (group.length === 0) return null; + const wantBold = exact.length > 0 ? false : wanted.bold; + const wantItalic = exact.length > 0 ? false : wanted.italic; + let best: LocalFont | null = null; + let bestScore = Number.NEGATIVE_INFINITY; + for (const font of group) { + const score = scoreFace(font, wantBold, wantItalic); + if (score > bestScore) { + best = font; + bestScore = score; + } + } + return best; +} + +interface BlobSource { + blob?: () => Promise; +} + +interface BlobBytes { + arrayBuffer?: () => Promise; +} + +async function readFaceBytes(source: unknown): Promise { + if (!source || typeof source !== "object") return null; + const read = (source as BlobSource).blob; + if (typeof read !== "function") return null; + try { + const blob = await read.call(source); + if (!blob || typeof blob !== "object") return null; + const toBuffer = (blob as BlobBytes).arrayBuffer; + if (typeof toBuffer !== "function") return null; + const bytes = new Uint8Array(await toBuffer.call(blob)); + return bytes.length > 0 ? bytes : null; + } catch { + return null; + } +} + +const bytesByFamily = new Map(); +const bytesPending = new Map>(); + +/** Already-read bytes for a family, or null. Never prompts, never awaits. */ +export function getLocalFontBytes(family: string): Uint8Array | null { + return bytesByFamily.get(normaliseName(family)) ?? null; +} + +// The font file bytes behind a family name, cached for the session. Null when +// unsupported, denied, unmatched, or unreadable - never throws. +export async function loadLocalFontBytes( + family: string, +): Promise { + const key = normaliseName(family); + if (!key) return null; + const cached = bytesByFamily.get(key); + if (cached) return cached; + const inFlight = bytesPending.get(key); + if (inFlight) return inFlight; + const job = (async (): Promise => { + const fonts = await listLocalFonts(); + if (!fonts) return null; + const picked = pickLocalFontFace(fonts, family); + if (!picked) return null; + const entry = faceEntries.find((candidate) => candidate.font === picked); + const bytes = entry ? await readFaceBytes(entry.source) : null; + if (bytes) bytesByFamily.set(key, bytes); + return bytes; + })(); + bytesPending.set(key, job); + try { + return await job; + } finally { + // Only successes are cached: a transient blob failure must not disable + // this family for the rest of the session. + bytesPending.delete(key); + } +} diff --git a/frontend/editor/src/core/tools/pdfTextEditor/util/objectTransform.ts b/frontend/editor/src/core/tools/pdfTextEditor/util/objectTransform.ts new file mode 100644 index 0000000000..7cdf476a0d --- /dev/null +++ b/frontend/editor/src/core/tools/pdfTextEditor/util/objectTransform.ts @@ -0,0 +1,64 @@ +import type { WrappedPdfiumModule } from "@embedpdf/pdfium"; +import type { Affine } from "@app/tools/pdfTextEditor/types"; +import { + composeAffine, + invertAffine, +} from "@app/tools/pdfTextEditor/model/affine"; + +interface ClipPathModule { + FPDFPageObj_TransformClipPath?: ( + obj: number, + a: number, + b: number, + c: number, + d: number, + e: number, + f: number, + ) => void; +} + +/** Transform an object's clip path by the same matrix. No-op when unclipped. */ +function transformClip(m: WrappedPdfiumModule, ptr: number, t: Affine): void { + try { + (m as unknown as ClipPathModule).FPDFPageObj_TransformClipPath?.( + ptr, + t.a, + t.b, + t.c, + t.d, + t.e, + t.f, + ); + } catch { + /* best-effort */ + } +} + +// Move an object AND its clip path. Transforming the object alone leaves the +// clip behind, so moved clipped content gets sliced by a stale rectangle. +export function transformObject( + m: WrappedPdfiumModule, + ptr: number, + a: number, + b: number, + c: number, + d: number, + e: number, + f: number, +): void { + if (!ptr) return; + m.FPDFPageObj_Transform(ptr, a, b, c, d, e, f); + transformClip(m, ptr, { a, b, c, d, e, f }); +} + +// Follow an ABSOLUTE matrix change with the clip. The page-space delta between +// two object matrices is `next · prev⁻¹`. +export function retargetClipPath( + m: WrappedPdfiumModule, + ptr: number, + prev: Affine, + next: Affine, +): void { + if (!ptr) return; + transformClip(m, ptr, composeAffine(next, invertAffine(prev))); +} diff --git a/frontend/editor/src/core/tools/pdfTextEditor/util/overlayPainter.ts b/frontend/editor/src/core/tools/pdfTextEditor/util/overlayPainter.ts new file mode 100644 index 0000000000..c931332802 --- /dev/null +++ b/frontend/editor/src/core/tools/pdfTextEditor/util/overlayPainter.ts @@ -0,0 +1,391 @@ +import { + fitTokenAdvance, + type TokenFit, +} from "@app/tools/pdfTextEditor/util/lineLayout"; +import { measureAdvancePx } from "@app/tools/pdfTextEditor/util/textMetrics"; + +export interface PaintToken { + text: string; + advancePx: number; +} + +export interface PaintLine { + tokens: PaintToken[]; + heightPx: number; + marginTopPx: number; + marginLeftPx: number; +} + +export interface PaintOptions { + font: string; + fontSizePx: number; + /** + * PDF advance per em for characters the run already contains, keyed by + * character. The only measurement of the document's own face available while + * the user is typing, so it is what newly typed glyphs are sized against. + */ + advanceEm?: Map | null; +} + +const LINE_ATTR = "data-pdf-editor-line"; +const TOKEN_ATTR = "data-pdf-editor-token"; + +export function paintLines( + el: HTMLElement, + lines: PaintLine[], + opts: PaintOptions, +): void { + const fragment = document.createDocumentFragment(); + lines.forEach((line, index) => { + const block = document.createElement("div"); + block.setAttribute(LINE_ATTR, String(index)); + // A painted block IS a line of the PDF: one text object, one pen origin, + // and the page cannot wrap it. So the block must not wrap or grow either. + // Letting it inherit `pre-wrap` from the container put a long line on two + // rows here and one row on the page, pushing every block below it a full + // line-height down - the box then overhung its own text by a row and the + // rendered text appeared to stay on the previous line. + block.style.height = `${line.heightPx}px`; + block.style.lineHeight = `${line.heightPx}px`; + block.style.marginTop = `${line.marginTopPx}px`; + block.style.marginLeft = `${line.marginLeftPx}px`; + block.style.whiteSpace = "pre"; + + if (line.tokens.length === 0) { + block.appendChild(document.createElement("br")); + } + for (const token of line.tokens) { + block.appendChild(tokenSpan(token, opts)); + } + fragment.appendChild(block); + }); + el.replaceChildren(fragment); +} + +function tokenSpan(token: PaintToken, opts: PaintOptions): HTMLSpanElement { + const span = document.createElement("span"); + span.setAttribute(TOKEN_ATTR, ""); + span.textContent = token.text; + span.dataset.adv = String(token.advancePx); + span.dataset.src = token.text; + applyFit(span, token, opts); + return span; +} + +function applyFit( + span: HTMLSpanElement, + token: PaintToken, + opts: PaintOptions, +): void { + const fit = tokenFitFor(token, opts); + span.style.letterSpacing = + fit.letterSpacingPx !== 0 ? `${fit.letterSpacingPx}px` : ""; + span.style.marginRight = + fit.marginRightPx !== 0 ? `${fit.marginRightPx}px` : ""; +} + +export function refitTokens(el: HTMLElement, opts: PaintOptions): void { + refit(el, opts, false); +} + +/** Re-fit only the tokens the user has typed into - cheap enough per keystroke. */ +export function refitEditedTokens(el: HTMLElement, opts: PaintOptions): void { + refit(el, opts, true); +} + +function refit( + el: HTMLElement, + opts: PaintOptions, + changedOnly: boolean, +): void { + for (const span of el.querySelectorAll(`[${TOKEN_ATTR}]`)) { + const advance = Number(span.dataset.adv); + if (!Number.isFinite(advance)) continue; + const text = span.textContent ?? ""; + const source = span.dataset.src ?? ""; + if (text === source) { + // An estimate the user has since backspaced away is sized for text that + // is no longer there, so replace it even on the per-keystroke pass. + if (!changedOnly || span.dataset.est) { + delete span.dataset.est; + applyFit(span, { text, advancePx: advance }, opts); + } + continue; + } + const target = predictedAdvance(text, source, advance, opts); + if (target === null) continue; + span.dataset.est = "1"; + applyFit(span, { text, advancePx: target }, opts); + } +} + +/** + * Where the PDF will advance the pen for a token the user has typed into. + * + * A token is painted at the width the PDF advances, not the width the browser + * lays the same string out at - the two differ by 10-15% whenever the document + * face isn't the one the browser has, and by a different amount per glyph. The + * engine only re-measures once typing pauses, so until then each character is + * priced from the document's own advances where the run already has that + * character, and from the token's browser-to-PDF ratio where it does not. + * Leaving the pre-edit fit in place instead smears a five-character correction + * across a thirty-character word. + */ +function predictedAdvance( + text: string, + source: string, + sourceAdvancePx: number, + opts: PaintOptions, +): number | null { + if (text === "" || source === "") return null; + const sourceNatural = measureAdvancePx(source, opts.font); + if (!(sourceNatural > 0) || !(sourceAdvancePx > 0)) return null; + const ratio = sourceAdvancePx / sourceNatural; + const table = opts.advanceEm; + if (!table || table.size === 0) { + const natural = measureAdvancePx(text, opts.font); + return natural > 0 ? natural * ratio : null; + } + let total = 0; + for (const ch of text) { + const em = table.get(ch); + total += + em === undefined + ? measureAdvancePx(ch, opts.font) * ratio + : em * opts.fontSizePx; + } + return total > 0 ? total : null; +} + +function tokenFitFor(token: PaintToken, opts: PaintOptions): TokenFit { + const natural = measureAdvancePx(token.text, opts.font); + return fitTokenAdvance( + [...token.text].length, + natural, + token.advancePx, + opts.fontSizePx, + ); +} + +export function paintPlainText(el: HTMLElement, text: string): void { + el.innerText = text; +} + +/** + * Lines held by one painted line block. + * + * A recursive walk that emits one break per
- Firefox puts a manual break + * INSIDE the token span it split, so the walk has to descend. Under the + * blocks' `white-space: pre` this agrees with layout the way innerText does, + * without innerText's forced layout flush (the old reader spent a flush per + * block per keystroke). A block the browser emptied keeps a filler break that + * would otherwise read as a newline of its own; the filler is not always a + * direct
- pressing Enter at the end of a line leaves Chrome an empty + * clone of the token span with the
inside it. An emptied block is one + * empty line however the browser spells it, so key off the absence of text. + */ +function blockLines(element: HTMLElement): string[] { + if ((element.textContent ?? "") === "") return [""]; + const lines: string[] = [""]; + const walk = (node: Node): void => { + if (node.nodeType === Node.TEXT_NODE) { + lines[lines.length - 1] += node.textContent ?? ""; + return; + } + if (node instanceof HTMLElement && node.tagName === "BR") { + lines.push(""); + return; + } + for (const child of Array.from(node.childNodes)) walk(child); + }; + for (const child of Array.from(element.childNodes)) walk(child); + return lines; +} + +/** + * Read an overlay back into the model's plain text. The inverse of paintLines + * and paintPlainText, so it lives beside them: when the two disagree about how + * many lines the DOM holds, the run is re-emitted at the wrong baselines. + */ +export function readOverlayText(element: HTMLElement): string { + const children = Array.from(element.childNodes); + if (children.length === 0) return ""; + // Seeded with the line a leading
would terminate; without it a model + // text starting with a newline lost its blank first line, pulling every line + // below it up one leading. + const lines: string[] = [""]; + let lastWasTrailingBr = false; + let sawBlock = false; + for (const node of children) { + if (node.nodeType === Node.TEXT_NODE) { + lines[lines.length - 1] += node.textContent ?? ""; + lastWasTrailingBr = false; + continue; + } + if (!(node instanceof HTMLElement)) continue; + if (node.tagName === "BR") { + lines.push(""); + lastWasTrailingBr = true; + continue; + } + // Block children carry whole lines, so the seed is not one of them. + if (!sawBlock && lines.length === 1 && lines[0] === "") lines.length = 0; + sawBlock = true; + for (const line of blockLines(node)) lines.push(line); + lastWasTrailingBr = false; + } + // Browsers park a filler
at the end of a contenteditable; innerText + // ignores it and so must we. + if (lastWasTrailingBr) lines.pop(); + return lines.join("\n").replace(/\u00A0/g, " "); +} + +export function isLinePainted(el: HTMLElement): boolean { + return el.querySelector(`[${LINE_ATTR}]`) !== null; +} + +function lineBlocks(el: HTMLElement): HTMLElement[] { + return Array.from(el.children).filter( + (c): c is HTMLElement => + c instanceof HTMLElement && c.hasAttribute(LINE_ATTR), + ); +} + +/** + * Characters of the run's model text that precede the caret. Computed by + * reading a truncated clone through the SAME walk that produces the model + * text, so any DOM the browser improvises mid-edit (a break inside a token + * span, a stray sibling div Firefox wraps typed text in, a caret parked on + * the container) yields an offset consistent with readOverlayText. The old + * block-by-block count returned null for those shapes, the repaint then + * skipped the restore, and the next keystroke landed at the start of the run. + */ +export function plainCaretOffset(el: HTMLElement): number | null { + const selection = window.getSelection(); + if (!selection || selection.rangeCount === 0) return null; + const { focusNode, focusOffset } = selection; + if (!focusNode || !el.contains(focusNode)) return null; + const range = document.createRange(); + try { + range.setStart(el, 0); + range.setEnd(focusNode, focusOffset); + } catch { + return null; + } + const host = document.createElement("div"); + host.appendChild(range.cloneContents()); + const chars = readOverlayText(host).length; + // A caret parked on the container BETWEEN two line children sits at the + // start of the next line - one past the end of the truncated text. Past the + // last line child it belongs at that line's end, not on a fresh one. + if (focusNode === el) { + const idx = Math.min(focusOffset, el.childNodes.length); + const children = Array.from(el.childNodes); + const isLineChild = (n: Node) => + n instanceof HTMLElement && n.tagName !== "BR"; + if ( + children.slice(0, idx).some(isLineChild) && + children.slice(idx).some(isLineChild) + ) { + return chars + 1; + } + } + return chars; +} + +/** + * Move a caret parked on the CONTAINER itself into the painted block it sits + * beside. Left there, Firefox applies the next insertText as a bare sibling of + * the line divs (often wrapped in a fresh div), which reads back as an extra + * model line the user never typed. + */ +export function normalizeContainerCaret( + el: HTMLElement, + selection: Selection, +): void { + if (selection.rangeCount === 0) return; + // A CARET only. Firefox anchors a select-all on the container too, and + // collapsing that just before a Delete turns "replace the line" into + // "delete one character". + if (!selection.isCollapsed) return; + const { anchorNode, anchorOffset } = selection; + if (anchorNode !== el) return; + const blocks = lineBlocks(el); + if (blocks.length === 0) return; + // Container offset N sits between child N-1 and child N: land at the end of + // the block before it (or the start of the first block for offset 0). + let target: HTMLElement | null = null; + for ( + let i = Math.min(anchorOffset, el.childNodes.length) - 1; + i >= 0; + i -= 1 + ) { + const child = el.childNodes[i]; + if (child instanceof HTMLElement && child.hasAttribute(LINE_ATTR)) { + target = child; + break; + } + } + if (target) { + let node: Node = target; + while (node.lastChild) node = node.lastChild; + const at = + node.nodeType === Node.TEXT_NODE ? (node.textContent ?? "").length : 0; + setCollapsed(selection, node, at); + return; + } + let first: Node = blocks[0]; + while (first.firstChild) first = first.firstChild; + setCollapsed(selection, first, 0); +} + +export function restoreCaretOffset(el: HTMLElement, offset: number): void { + const selection = window.getSelection(); + if (!selection) return; + const target = Math.max(0, offset); + + const blocks = lineBlocks(el); + let scope: HTMLElement = el; + let remaining = target; + if (blocks.length > 0) { + scope = blocks[blocks.length - 1]; + remaining = (scope.textContent ?? "").length; + let before = 0; + for (const block of blocks) { + const length = (block.textContent ?? "").length; + if (target <= before + length) { + scope = block; + remaining = target - before; + break; + } + before += length + 1; + } + } + + const walker = document.createTreeWalker(scope, NodeFilter.SHOW_TEXT); + let seen = 0; + let node = walker.nextNode(); + while (node) { + const length = (node.nodeValue ?? "").length; + if (seen + length >= remaining) { + setCollapsed(selection, node, remaining - seen); + return; + } + seen += length; + node = walker.nextNode(); + } + setCollapsed(selection, scope, 0); +} + +function setCollapsed(selection: Selection, node: Node, offset: number): void { + const range = document.createRange(); + try { + range.setStart(node, offset); + } catch { + range.selectNodeContents(node); + range.collapse(false); + } + range.collapse(true); + selection.removeAllRanges(); + selection.addRange(range); +} diff --git a/frontend/editor/src/core/tools/pdfTextEditor/util/pageFonts.ts b/frontend/editor/src/core/tools/pdfTextEditor/util/pageFonts.ts new file mode 100644 index 0000000000..e2dc203cfc --- /dev/null +++ b/frontend/editor/src/core/tools/pdfTextEditor/util/pageFonts.ts @@ -0,0 +1,157 @@ +import type { PageSnapshot } from "@app/tools/pdfTextEditor/types"; +import { getCachedFontGlyphMap } from "@app/tools/pdfTextEditor/charcode/CmapResolver"; + +// Editability status of a font as the PDF text editor can determine it purely +// client-side (from PDFium), without the backend JSON font model. +export type FontStatus = "standard" | "embedded" | "subset"; + +// Whether the font has real glyphs for the basic alphanumerics (a-z A-Z 0-9). +export interface GlyphCoverage { + known: boolean; + missing: string[]; +} + +export interface PageFont { + /** Stable de-dupe key (display name + status). */ + key: string; + /** Display family name with any subset tag stripped. */ + name: string; + status: FontStatus; + /** 1-based page numbers this font appears on (across loaded pages). */ + pages: number[]; + /** Basic-alphanumeric glyph coverage (from the loader-primed cmap cache). */ + coverage: GlyphCoverage; +} + +/** Code points for a-z, A-Z, 0-9 - the "can I type a letter/number?" probe. */ +const ALNUM_CODEPOINTS: readonly number[] = (() => { + const out: number[] = []; + for (let c = 0x30; c <= 0x39; c++) out.push(c); // 0-9 + for (let c = 0x41; c <= 0x5a; c++) out.push(c); // A-Z + for (let c = 0x61; c <= 0x7a; c++) out.push(c); // a-z + return out; +})(); + +/** Pure: which of a-z A-Z 0-9 are absent from a Unicode→glyphId cmap. */ +export function missingAlnumFromCmap(cmap: Map): string[] { + const out: string[] = []; + for (const cp of ALNUM_CODEPOINTS) + if (!cmap.has(cp)) out.push(String.fromCodePoint(cp)); + return out; +} + +/** Parse the live PDFium font handle out of a `pdf::` fontId. */ +function fontHandleOf(fontId: string): number { + if (!fontId.startsWith("pdf:")) return 0; + const n = Number(fontId.split(":")[1]); + return Number.isFinite(n) && n > 0 ? n : 0; +} + +/** a-zA-Z0-9 coverage for a font, from the loader-primed cache (no WASM). */ +function coverageFor(fontId: string, status: FontStatus): GlyphCoverage { + // Base-14 fonts carry the whole standard set - always full, no cmap needed. + if (status === "standard") return { known: true, missing: [] }; + const handle = fontHandleOf(fontId); + if (!handle) return { known: false, missing: [] }; + const cmap = getCachedFontGlyphMap(handle); + if (!cmap || cmap.size === 0) return { known: false, missing: [] }; + return { known: true, missing: missingAlnumFromCmap(cmap) }; +} + +// Symbol/ZapfDingbats are intentionally excluded: their a-z/A-Z slots are Greek +// letters / dingbats, not Latin alphanumerics. +const STANDARD_14 = [ + "helvetica", + "arial", + "times", + "timesroman", + "timesnewroman", + "courier", + "couriernew", +]; + +// Style suffixes a genuine base-14 family may carry once separators are stripped +// (e.g. "Helvetica-BoldOblique", "ArialMT", "Times-Roman"). +const BASE14_STYLE_SUFFIX = /^(bold|italic|oblique|regular|roman|mt|ps)+$/; + +/** Pull the readable family from a fontId (`pdf::` or `base14:`). */ +function familyOf(fontId: string): string { + if (fontId.startsWith("base14:")) return fontId.slice("base14:".length); + const parts = fontId.split(":"); + return parts.length >= 3 ? parts.slice(2).join(":") : fontId; +} + +/** Subset fonts carry a 6-letter "ABCDEF+" tag; strip it for display. */ +function stripSubsetTag(name: string): string { + return name.replace(/^[A-Z]{6}\+/, ""); +} + +// Weight/width modifiers that mark a DIFFERENT font even when the name starts +// with a base-14 root (e.g. "Arial Black", "Helvetica Neue Condensed"). +const NON_BASE14_MODIFIERS = [ + "black", + "rounded", + "narrow", + "condensed", + "light", + "thin", + "hairline", + "semibold", + "demibold", + "demi", + "medium", + "heavy", + "ultra", + "display", + "neue", +]; + +function isStandard14(fontId: string): boolean { + // Callers pass the full fontId (`pdf::Family`); reduce to the bare + // family first so the `pdf::` prefix can't defeat the prefix match. + const f = stripSubsetTag(familyOf(fontId)) + .toLowerCase() + .replace(/[-_\s]/g, ""); + if (NON_BASE14_MODIFIERS.some((mod) => f.includes(mod))) return false; + // Exact match, or a base-14 root whose remainder is ONLY a recognised style + // suffix (Bold/Italic/Oblique/MT/PS...). + return STANDARD_14.some( + (p) => + f === p || + (f.startsWith(p) && BASE14_STYLE_SUFFIX.test(f.slice(p.length))), + ); +} + +// Group every run across the given (loaded) pages into a de-duplicated list of +// fonts with an editability status. +export function analyzePageFonts(pages: PageSnapshot[]): PageFont[] { + const map = new Map(); + for (const page of pages) { + for (const run of page.runs) { + const name = stripSubsetTag(familyOf(run.fontId)) || "Unknown font"; + let status: FontStatus; + if (run.fontId.startsWith("base14:") || isStandard14(run.fontId)) { + status = "standard"; + } else if (run.fontSubset) { + status = "subset"; + } else { + status = "embedded"; + } + const key = `${name}|${status}`; + const pageNo = page.pageIndex + 1; + const existing = map.get(key); + if (existing) { + if (!existing.pages.includes(pageNo)) existing.pages.push(pageNo); + } else { + map.set(key, { + key, + name, + status, + pages: [pageNo], + coverage: coverageFor(run.fontId, status), + }); + } + } + } + return Array.from(map.values()).sort((a, b) => a.name.localeCompare(b.name)); +} diff --git a/frontend/editor/src/core/tools/pdfTextEditor/util/sha256.ts b/frontend/editor/src/core/tools/pdfTextEditor/util/sha256.ts new file mode 100644 index 0000000000..546179989d --- /dev/null +++ b/frontend/editor/src/core/tools/pdfTextEditor/util/sha256.ts @@ -0,0 +1,89 @@ +/** Synchronous pure-JS SHA-256 (FIPS 180-4), returning lowercase hex. */ + +// First 32 bits of the fractional parts of the cube roots of primes 2..311. +const K = new Uint32Array([ + 0x428a2f98, 0x71374491, 0xb5c0fbcf, 0xe9b5dba5, 0x3956c25b, 0x59f111f1, + 0x923f82a4, 0xab1c5ed5, 0xd807aa98, 0x12835b01, 0x243185be, 0x550c7dc3, + 0x72be5d74, 0x80deb1fe, 0x9bdc06a7, 0xc19bf174, 0xe49b69c1, 0xefbe4786, + 0x0fc19dc6, 0x240ca1cc, 0x2de92c6f, 0x4a7484aa, 0x5cb0a9dc, 0x76f988da, + 0x983e5152, 0xa831c66d, 0xb00327c8, 0xbf597fc7, 0xc6e00bf3, 0xd5a79147, + 0x06ca6351, 0x14292967, 0x27b70a85, 0x2e1b2138, 0x4d2c6dfc, 0x53380d13, + 0x650a7354, 0x766a0abb, 0x81c2c92e, 0x92722c85, 0xa2bfe8a1, 0xa81a664b, + 0xc24b8b70, 0xc76c51a3, 0xd192e819, 0xd6990624, 0xf40e3585, 0x106aa070, + 0x19a4c116, 0x1e376c08, 0x2748774c, 0x34b0bcb5, 0x391c0cb3, 0x4ed8aa4a, + 0x5b9cca4f, 0x682e6ff3, 0x748f82ee, 0x78a5636f, 0x84c87814, 0x8cc70208, + 0x90befffa, 0xa4506ceb, 0xbef9a3f7, 0xc67178f2, +]); + +/** SHA-256 of `data`, as 64 lowercase hex chars. */ +export function sha256Hex(data: Uint8Array): string { + // Message schedule + working state. + const h = new Uint32Array([ + 0x6a09e667, 0xbb67ae85, 0x3c6ef372, 0xa54ff53a, 0x510e527f, 0x9b05688c, + 0x1f83d9ab, 0x5be0cd19, + ]); + const w = new Uint32Array(64); + + // Padded length: message + 0x80 + zeros + 8-byte big-endian bit length, + // rounded up to a 64-byte multiple. + const bitLenLo = (data.length << 3) >>> 0; + const bitLenHi = Math.floor(data.length / 0x20000000); + const paddedLen = ((data.length + 8) >> 6) * 64 + 64; + const padded = new Uint8Array(paddedLen); + padded.set(data); + padded[data.length] = 0x80; + const dv = new DataView(padded.buffer); + dv.setUint32(paddedLen - 8, bitLenHi); + dv.setUint32(paddedLen - 4, bitLenLo); + + for (let off = 0; off < paddedLen; off += 64) { + for (let i = 0; i < 16; i++) w[i] = dv.getUint32(off + i * 4); + for (let i = 16; i < 64; i++) { + const s0 = + (rotr(w[i - 15], 7) ^ rotr(w[i - 15], 18) ^ (w[i - 15] >>> 3)) >>> 0; + const s1 = + (rotr(w[i - 2], 17) ^ rotr(w[i - 2], 19) ^ (w[i - 2] >>> 10)) >>> 0; + w[i] = (w[i - 16] + s0 + w[i - 7] + s1) >>> 0; + } + let a = h[0], + b = h[1], + c = h[2], + d = h[3], + e = h[4], + f = h[5], + g = h[6], + hh = h[7]; + for (let i = 0; i < 64; i++) { + const S1 = (rotr(e, 6) ^ rotr(e, 11) ^ rotr(e, 25)) >>> 0; + const ch = ((e & f) ^ (~e & g)) >>> 0; + const t1 = (hh + S1 + ch + K[i] + w[i]) >>> 0; + const S0 = (rotr(a, 2) ^ rotr(a, 13) ^ rotr(a, 22)) >>> 0; + const maj = ((a & b) ^ (a & c) ^ (b & c)) >>> 0; + const t2 = (S0 + maj) >>> 0; + hh = g; + g = f; + f = e; + e = (d + t1) >>> 0; + d = c; + c = b; + b = a; + a = (t1 + t2) >>> 0; + } + h[0] = (h[0] + a) >>> 0; + h[1] = (h[1] + b) >>> 0; + h[2] = (h[2] + c) >>> 0; + h[3] = (h[3] + d) >>> 0; + h[4] = (h[4] + e) >>> 0; + h[5] = (h[5] + f) >>> 0; + h[6] = (h[6] + g) >>> 0; + h[7] = (h[7] + hh) >>> 0; + } + + let hex = ""; + for (let i = 0; i < 8; i++) hex += h[i].toString(16).padStart(8, "0"); + return hex; +} + +function rotr(x: number, n: number): number { + return ((x >>> n) | (x << (32 - n))) >>> 0; +} diff --git a/frontend/editor/src/core/tools/pdfTextEditor/util/spellcheck.ts b/frontend/editor/src/core/tools/pdfTextEditor/util/spellcheck.ts new file mode 100644 index 0000000000..5d0df643bf --- /dev/null +++ b/frontend/editor/src/core/tools/pdfTextEditor/util/spellcheck.ts @@ -0,0 +1,197 @@ +import { useSyncExternalStore } from "react"; + +// The browser's own spell-check engine does the checking; this module only +// owns the preference (on/off + dictionary language) that drives it. + +/** BCP-47 tag, or `SPELLCHECK_AUTO` to follow the document language. */ +export type SpellcheckLang = string; + +export interface SpellcheckPreference { + enabled: boolean; + lang: SpellcheckLang; +} + +export interface SpellcheckLanguage { + /** BCP-47 tag handed to the browser as the `lang` attribute. */ + tag: string; + /** English name; the UI localises it via Intl.DisplayNames when it can. */ + label: string; +} + +export const SPELLCHECK_AUTO = "auto"; + +export const SPELLCHECK_LANGUAGES: readonly SpellcheckLanguage[] = [ + { tag: "en-US", label: "English (United States)" }, + { tag: "en-GB", label: "English (United Kingdom)" }, + { tag: "de", label: "German" }, + { tag: "fr", label: "French" }, + { tag: "es", label: "Spanish" }, + { tag: "it", label: "Italian" }, + { tag: "pt", label: "Portuguese" }, + { tag: "ar", label: "Arabic" }, + { tag: "hi", label: "Hindi" }, +]; + +// Off by default: an unfocused overlay renders its text transparent, so +// stray squiggles would sit over the PDFium bitmap with nothing under them. +export const DEFAULT_SPELLCHECK_PREFERENCE: SpellcheckPreference = + Object.freeze({ + enabled: false, + lang: SPELLCHECK_AUTO, + }); + +const STORAGE_KEY = "stirling.pdfTextEditor.spellcheck"; + +// Deliberately loose: enough to reject junk ("not a tag", "") without +// re-implementing BCP-47, which the browser validates anyway. +const TAG_PATTERN = /^[A-Za-z]{2,8}(?:-[A-Za-z0-9]{1,8})*$/; + +function storage(): Storage | null { + try { + if (typeof window === "undefined") return null; + return window.localStorage ?? null; + } catch { + /* localStorage may be absent or throw on access (blocked cookies) */ + return null; + } +} + +function readStored(): SpellcheckPreference | null { + let raw: string | null = null; + try { + raw = storage()?.getItem(STORAGE_KEY) ?? null; + } catch { + /* quota / privacy modes can throw on read */ + return null; + } + if (!raw) return null; + try { + const parsed: unknown = JSON.parse(raw); + if (!parsed || typeof parsed !== "object") return null; + const record = parsed as Record; + const lang = + typeof record.lang === "string" && record.lang.trim() + ? record.lang.trim() + : DEFAULT_SPELLCHECK_PREFERENCE.lang; + return { + enabled: + typeof record.enabled === "boolean" + ? record.enabled + : DEFAULT_SPELLCHECK_PREFERENCE.enabled, + lang, + }; + } catch { + /* corrupted entry - fall back to the default rather than crash */ + return null; + } +} + +function writeStored(pref: SpellcheckPreference): void { + try { + storage()?.setItem(STORAGE_KEY, JSON.stringify(pref)); + } catch { + /* best-effort: the in-memory value still applies for this session */ + } +} + +/** Module singleton so both React roots observe one preference. */ +class SpellcheckStore { + private pref: SpellcheckPreference | null = null; + private listeners: Set<(p: SpellcheckPreference) => void> = new Set(); + + get(): SpellcheckPreference { + if (!this.pref) + this.pref = readStored() ?? { ...DEFAULT_SPELLCHECK_PREFERENCE }; + return this.pref; + } + + set(next: SpellcheckPreference): void { + const current = this.get(); + const value: SpellcheckPreference = { + enabled: next.enabled, + lang: next.lang.trim() || SPELLCHECK_AUTO, + }; + if (value.enabled === current.enabled && value.lang === current.lang) + return; + this.pref = value; + writeStored(value); + this.notify(value); + } + + subscribe(listener: (p: SpellcheckPreference) => void): () => void { + this.listeners.add(listener); + return () => { + this.listeners.delete(listener); + }; + } + + reset(): void { + this.pref = null; + this.listeners.clear(); + } + + private notify(value: SpellcheckPreference): void { + // Snapshot + guard: a subscriber may unsubscribe others or throw; + // iterating the live Set would skip listeners or abort early. + for (const l of Array.from(this.listeners)) { + try { + l(value); + } catch { + /* one listener throwing must not stop the rest */ + } + } + } +} + +const store = new SpellcheckStore(); + +export function getSpellcheckPreference(): SpellcheckPreference { + return store.get(); +} + +export function setSpellcheckPreference(next: SpellcheckPreference): void { + store.set(next); +} + +export function setSpellcheckEnabled(enabled: boolean): void { + store.set({ ...store.get(), enabled }); +} + +export function setSpellcheckLang(lang: SpellcheckLang): void { + store.set({ ...store.get(), lang }); +} + +export function subscribeSpellcheck( + listener: (p: SpellcheckPreference) => void, +): () => void { + return store.subscribe(listener); +} + +/** Test-only - drop the cached preference and every subscriber. */ +export function __resetSpellcheckForTests(): void { + store.reset(); +} + +function normalizeTag(tag: string | null | undefined): string | null { + if (typeof tag !== "string") return null; + const trimmed = tag.trim(); + return TAG_PATTERN.test(trimmed) ? trimmed : null; +} + +/** The `lang` for an editable overlay, or null to leave it to the browser. */ +export function resolveLang( + pref: SpellcheckPreference, + documentLang: string | null | undefined, +): string | null { + if (!pref.enabled) return null; + if (pref.lang !== SPELLCHECK_AUTO) return normalizeTag(pref.lang); + return normalizeTag(documentLang); +} + +export function useSpellcheckPreference(): SpellcheckPreference { + return useSyncExternalStore( + subscribeSpellcheck, + getSpellcheckPreference, + getSpellcheckPreference, + ); +} diff --git a/frontend/editor/src/core/tools/pdfTextEditor/util/textMatching.ts b/frontend/editor/src/core/tools/pdfTextEditor/util/textMatching.ts new file mode 100644 index 0000000000..1ad1960685 --- /dev/null +++ b/frontend/editor/src/core/tools/pdfTextEditor/util/textMatching.ts @@ -0,0 +1,130 @@ +export interface MatchOptions { + matchCase?: boolean; + wholeWord?: boolean; + ignoreAccents?: boolean; +} + +export interface TextMatch { + start: number; + end: number; +} + +const ASCII_MAX = 0x7f; +const COMBINING_MARK = /\p{M}/gu; +const WORD_CHAR = /[\p{L}\p{N}\p{M}_]/u; + +/** Scanned rather than matched: a regex for this needs control characters. */ +function isAscii(text: string): boolean { + for (let i = 0; i < text.length; i += 1) { + if (text.charCodeAt(i) > ASCII_MAX) return false; + } + return true; +} + +// Length-stable fold: index i of the result maps to index i of the input, so +// match offsets stay valid against the untouched original. +export function foldForSearch(text: string, opts: MatchOptions = {}): string { + const lower = opts.matchCase !== true; + const strip = opts.ignoreAccents === true; + if (!lower && !strip) return text; + // ASCII can never change length under either fold, and this is the hot path. + if (isAscii(text)) return lower ? text.toLowerCase() : text; + let out = ""; + for (const ch of text) out += foldChar(ch, lower, strip); + return out; +} + +function foldChar(ch: string, lower: boolean, strip: boolean): string { + let c = ch; + if (lower) { + const lowered = c.toLowerCase(); + if (lowered.length === c.length) c = lowered; + } + if (strip) { + const stripped = c.normalize("NFD").replace(COMBINING_MARK, ""); + if (stripped.length === c.length) c = stripped; + } + return c; +} + +export function isWordChar(ch: string | null): boolean { + return ch !== null && ch.length > 0 && WORD_CHAR.test(ch); +} + +function codePointAt(text: string, index: number): string | null { + if (index < 0 || index >= text.length) return null; + const cp = text.codePointAt(index); + return cp === undefined ? null : String.fromCodePoint(cp); +} + +function codePointBefore(text: string, index: number): string | null { + if (index <= 0 || index > text.length) return null; + const unit = text.charCodeAt(index - 1); + if (unit >= 0xdc00 && unit <= 0xdfff && index >= 2) { + const high = text.charCodeAt(index - 2); + if (high >= 0xd800 && high <= 0xdbff) return text.slice(index - 2, index); + } + return text.charAt(index - 1); +} + +function isWholeWordAt(text: string, start: number, end: number): boolean { + return ( + !isWordChar(codePointBefore(text, start)) && + !isWordChar(codePointAt(text, end)) + ); +} + +/** Non-overlapping matches, left to right. Offsets index the original. */ +export function findMatches( + haystack: string, + needle: string, + opts: MatchOptions = {}, +): TextMatch[] { + if (needle.length === 0 || needle.length > haystack.length) return []; + const hay = foldForSearch(haystack, opts); + const pin = foldForSearch(needle, opts); + if (pin.length === 0 || pin.length > hay.length) return []; + const out: TextMatch[] = []; + let from = 0; + while (from <= hay.length - pin.length) { + const at = hay.indexOf(pin, from); + if (at < 0) break; + const end = at + pin.length; + if (opts.wholeWord === true && !isWholeWordAt(haystack, at, end)) { + from = at + 1; + continue; + } + out.push({ start: at, end }); + from = end; + } + return out; +} + +/** Literal splice: `$&` and friends in `replacement` are inserted verbatim. */ +export function replaceMatch( + text: string, + match: TextMatch, + replacement: string, +): string { + if (match.start < 0 || match.end > text.length || match.start > match.end) { + return text; + } + return text.slice(0, match.start) + replacement + text.slice(match.end); +} + +/** Same literal semantics as replaceMatch, for an ordered non-overlapping list. */ +export function replaceMatches( + text: string, + matches: TextMatch[], + replacement: string, +): string { + if (matches.length === 0) return text; + let out = ""; + let cursor = 0; + for (const m of matches) { + if (m.start < cursor || m.end > text.length || m.start > m.end) continue; + out += text.slice(cursor, m.start) + replacement; + cursor = m.end; + } + return out + text.slice(cursor); +} diff --git a/frontend/editor/src/core/tools/pdfTextEditor/util/textMetrics.ts b/frontend/editor/src/core/tools/pdfTextEditor/util/textMetrics.ts new file mode 100644 index 0000000000..98eddc1997 --- /dev/null +++ b/frontend/editor/src/core/tools/pdfTextEditor/util/textMetrics.ts @@ -0,0 +1,81 @@ +export interface FontMetrics { + ascent: number; + descent: number; +} + +let sharedCanvas: HTMLCanvasElement | null = null; +const metricsCache = new Map(); + +export function cssFontShorthand( + fontStyle: string, + fontWeight: number, + fontSizePx: number, + fontFamily: string, +): string { + return `${fontStyle} ${fontWeight} ${fontSizePx}px ${fontFamily}`; +} + +function context(): CanvasRenderingContext2D | null { + if (typeof document === "undefined") return null; + if (!sharedCanvas) sharedCanvas = document.createElement("canvas"); + return sharedCanvas.getContext("2d"); +} + +export function measureAdvancePx(text: string, font: string): number { + if (text === "") return 0; + const ctx = context(); + if (!ctx) return 0; + ctx.font = font; + if ("letterSpacing" in ctx) ctx.letterSpacing = "0px"; + return ctx.measureText(text).width; +} + +export function measureMaxLineWidth(text: string, font: string): number { + let max = 0; + for (const line of text.split(/\r?\n/)) { + const w = measureAdvancePx(line, font); + if (w > max) max = w; + } + return max; +} + +/** + * Width of the widest run of non-space characters - the narrowest a box can be + * and still show every glyph. No line breaking can beat it: there is nowhere + * inside a word to break, so a box narrower than this clips text whatever the + * wrap target says. + */ +export function measureLongestTokenWidth(text: string, font: string): number { + let max = 0; + for (const token of text.split(/\s+/)) { + if (!token) continue; + const w = measureAdvancePx(token, font); + if (w > max) max = w; + } + return max; +} + +export function measureFontMetrics( + font: string, + fontSizePx: number, +): FontMetrics { + const cached = metricsCache.get(font); + if (cached) return cached; + const fallback = { ascent: 0.8 * fontSizePx, descent: 0.2 * fontSizePx }; + const ctx = context(); + if (!ctx) return fallback; + ctx.font = font; + const m = ctx.measureText("Hg"); + const ascent = m.fontBoundingBoxAscent; + const descent = m.fontBoundingBoxDescent; + if (typeof ascent !== "number" || typeof descent !== "number") { + return fallback; + } + const metrics = { ascent, descent }; + metricsCache.set(font, metrics); + return metrics; +} + +export function resetTextMetricsCache(): void { + metricsCache.clear(); +} diff --git a/frontend/editor/src/core/tools/pdfTextEditor/util/toolbarState.ts b/frontend/editor/src/core/tools/pdfTextEditor/util/toolbarState.ts new file mode 100644 index 0000000000..9dba8a7b1c --- /dev/null +++ b/frontend/editor/src/core/tools/pdfTextEditor/util/toolbarState.ts @@ -0,0 +1,96 @@ +import { + isBoldFamily, + isItalicFamily, +} from "@app/tools/pdfTextEditor/util/fontFamily"; +import { canToggleItalic } from "@app/tools/pdfTextEditor/util/fontCapability"; +import type { LocalFont } from "@app/tools/pdfTextEditor/util/localFonts"; +import type { + PageSnapshot, + RGBA, + SelectionState, + ToolbarState, +} from "@app/tools/pdfTextEditor/types"; + +export const EMPTY_TOOLBAR: ToolbarState = { + fontFamily: null, + fontSize: null, + fill: null, + bold: false, + italic: false, + canItalic: false, + stroke: null, + strokeWidth: null, + mixed: { + fontFamily: false, + fontSize: false, + fill: false, + bold: false, + italic: false, + stroke: false, + strokeWidth: false, + }, +}; + +/** Collapse a multi-run selection into a single toolbar snapshot. */ +export function deriveToolbarState( + pages: PageSnapshot[], + selection: SelectionState, + localFonts: LocalFont[] | null = null, +): ToolbarState { + if (selection.runIds.length === 0) return EMPTY_TOOLBAR; + const selected = pages + .flatMap((p) => p.runs) + .filter((r) => selection.runIds.includes(r.id)); + if (selected.length === 0) return EMPTY_TOOLBAR; + const first = selected[0]; + const sameFamily = selected.every((r) => r.fontId === first.fontId); + const sameSize = selected.every((r) => r.fontSize === first.fontSize); + const sameFill = selected.every( + (r) => + r.fill.r === first.fill.r && + r.fill.g === first.fill.g && + r.fill.b === first.fill.b && + r.fill.a === first.fill.a, + ); + const firstStroke = first.stroke ?? null; + const sameStroke = selected.every((r) => + sameRgba(r.stroke ?? null, firstStroke), + ); + const firstStrokeWidth = first.strokeWidth ?? 0; + const sameStrokeWidth = selected.every( + (r) => (r.strokeWidth ?? 0) === firstStrokeWidth, + ); + const firstBold = isBoldFamily(first.fontId); + const firstItalic = isItalicFamily(first.fontId); + const sameBold = selected.every((r) => isBoldFamily(r.fontId) === firstBold); + const sameItalic = selected.every( + (r) => isItalicFamily(r.fontId) === firstItalic, + ); + return { + fontFamily: first.fontId, + fontSize: sameSize ? first.fontSize : null, + fill: sameFill ? first.fill : null, + bold: firstBold, + italic: firstItalic, + canItalic: canToggleItalic( + selected.map((r) => r.fontId), + localFonts, + ), + stroke: sameStroke ? firstStroke : null, + strokeWidth: sameStrokeWidth ? firstStrokeWidth : null, + mixed: { + fontFamily: !sameFamily, + fontSize: !sameSize, + fill: !sameFill, + bold: !sameBold, + italic: !sameItalic, + stroke: !sameStroke, + strokeWidth: !sameStrokeWidth, + }, + }; +} + +function sameRgba(a: RGBA | null, b: RGBA | null): boolean { + if (a === null || b === null) return a === b; + return a.r === b.r && a.g === b.g && a.b === b.b && a.a === b.a; +} diff --git a/frontend/editor/src/core/types/toolApiTypes.ts b/frontend/editor/src/core/types/toolApiTypes.ts index d980029624..fe77b94c49 100644 --- a/frontend/editor/src/core/types/toolApiTypes.ts +++ b/frontend/editor/src/core/types/toolApiTypes.ts @@ -485,6 +485,14 @@ export interface EmlToPdfRequest { */ maxAttachmentSizeMB?: number; } +export interface EncodeCharcodesRequest { + fontName?: string; + fontSha256?: string; + locatorChar?: string; + pageIndex?: number; + pdfBase64?: string; + text?: string; +} export type ExtractAttachmentsRequest = Record; export interface ExtractHeaderRequest { /** @@ -1536,6 +1544,7 @@ export type ToolEndpoint = | "/api/v1/general/merge-pdfs" | "/api/v1/general/multi-page-layout" | "/api/v1/general/overlay-pdfs" + | "/api/v1/general/pdf-text-editor/encode-charcodes" | "/api/v1/general/pdf-to-single-page" | "/api/v1/general/rearrange-pages" | "/api/v1/general/remove-image-pdf" @@ -1639,6 +1648,7 @@ export interface ToolApiParams { "/api/v1/general/merge-pdfs": MergePdfsRequest; "/api/v1/general/multi-page-layout": MergeMultiplePagesRequest; "/api/v1/general/overlay-pdfs": OverlayPdfsRequest; + "/api/v1/general/pdf-text-editor/encode-charcodes": EncodeCharcodesRequest; "/api/v1/general/pdf-to-single-page": GeneralPdfToSinglePageRequest; "/api/v1/general/rearrange-pages": RearrangePagesRequest; "/api/v1/general/remove-image-pdf": GeneralRemoveImagePdfRequest; @@ -1743,6 +1753,7 @@ export const TOOL_ENDPOINTS = [ "/api/v1/general/merge-pdfs", "/api/v1/general/multi-page-layout", "/api/v1/general/overlay-pdfs", + "/api/v1/general/pdf-text-editor/encode-charcodes", "/api/v1/general/pdf-to-single-page", "/api/v1/general/rearrange-pages", "/api/v1/general/remove-image-pdf", diff --git a/frontend/editor/src/core/ui/ToggleSwitch.tsx b/frontend/editor/src/core/ui/ToggleSwitch.tsx index 12f94fdac4..5c2c715633 100644 --- a/frontend/editor/src/core/ui/ToggleSwitch.tsx +++ b/frontend/editor/src/core/ui/ToggleSwitch.tsx @@ -13,6 +13,8 @@ export interface ToggleSwitchProps { disabled?: boolean; size?: "sm" | "md"; id?: string; + /** Placed on the

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