From 50d34fcca5e778ec9dd66ec5c6d2048a23a35a2e Mon Sep 17 00:00:00 2001
From: Anthony Stirling <77850077+Frooodle@users.noreply.github.com>
Date: Thu, 20 Aug 2026 11:47:05 +0000
Subject: [PATCH 01/52] Add download, rename and duplicate to the file actions
menu (#7536)
# Description of Changes
Adds expanded dropdown menu for download, rename and duplicate
---
## Checklist
### General
- [ ] I have read the [Contribution
Guidelines](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/CONTRIBUTING.md)
- [ ] I have read the [Stirling-PDF Developer
Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/DeveloperGuide.md)
(if applicable)
- [ ] I have read the [How to add new languages to
Stirling-PDF](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md)
(if applicable)
- [ ] I have performed a self-review of my own code
- [ ] My changes generate no new warnings
### Documentation
- [ ] I have updated relevant docs on [Stirling-PDF's doc
repo](https://github.com/Stirling-Tools/Stirling-Tools.github.io/blob/main/docs/)
(if functionality has heavily changed)
- [ ] I have read the section [Add New Translation
Tags](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md#add-new-translation-tags)
(for new translation tags only)
### Translations (if applicable)
- [ ] I ran
[`scripts/counter_translation.py`](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/docs/counter_translation.md)
### UI Changes (if applicable)
- [ ] Screenshots or videos demonstrating the UI changes are attached
(e.g., as comments or direct attachments in the PR)
### Testing (if applicable)
- [ ] I have run `task check` to verify linters, typechecks, and tests
pass
- [ ] I have tested my changes locally. Refer to the [Testing
Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/DeveloperGuide.md#7-testing)
for more details.
---
.../public/locales/en-US/translation.toml | 15 +
.../core/components/filesPage/FileGrid.tsx | 552 +++++++++---------
.../components/filesPage/FileManagerView.tsx | 101 ++++
.../core/components/shared/FileSidebar.tsx | 137 +++++
.../components/shared/FileSidebarFileItem.css | 22 +
.../components/shared/FileSidebarFileItem.tsx | 351 +++++++----
.../shared/RenameFileDialog.stories.tsx | 39 ++
.../components/shared/RenameFileDialog.tsx | 140 +++++
.../core/components/shared/WorkbenchBar.tsx | 6 +-
.../editor/src/core/hooks/useFileHandler.ts | 4 +
.../tests/stubbed/file-actions-menu.spec.ts | 167 ++++++
.../src/core/utils/duplicateFile.test.ts | 130 +++++
.../editor/src/core/utils/duplicateFile.ts | 85 +++
frontend/editor/src/core/utils/fileUtils.ts | 11 +
14 files changed, 1373 insertions(+), 387 deletions(-)
create mode 100644 frontend/editor/src/core/components/shared/RenameFileDialog.stories.tsx
create mode 100644 frontend/editor/src/core/components/shared/RenameFileDialog.tsx
create mode 100644 frontend/editor/src/core/tests/stubbed/file-actions-menu.spec.ts
create mode 100644 frontend/editor/src/core/utils/duplicateFile.test.ts
create mode 100644 frontend/editor/src/core/utils/duplicateFile.ts
diff --git a/frontend/editor/public/locales/en-US/translation.toml b/frontend/editor/public/locales/en-US/translation.toml
index 01db314031..c2287ace9c 100644
--- a/frontend/editor/public/locales/en-US/translation.toml
+++ b/frontend/editor/public/locales/en-US/translation.toml
@@ -3897,8 +3897,10 @@ collapse = "Collapse sidebar"
customizeGroups = "Customize groups"
dataLostBody = "This browser lost this file's contents. Upload it again to keep working with it."
dataLostTitle = "File data is unavailable"
+downloadFailed = "Download failed"
dropHint = "Open files to get started"
dropToAdd = "Drop files to add"
+duplicateFailed = "Could not duplicate file"
expand = "Expand sidebar"
googleDrive = "Google Drive"
googleDriveDisabled = "Google Drive is not configured"
@@ -3918,8 +3920,10 @@ closeViewer = "Close viewer"
dataLost = "Data lost"
dataLostTooltip = "This browser lost this file's contents. Upload it again to keep working with it."
delete = "Delete"
+duplicate = "Duplicate"
moreActions = "More actions"
openInViewer = "Open in viewer"
+rename = "Rename"
savedToServer = "Saved to server"
updateOnServer = "Update on server"
uploadToServer = "Upload to server"
@@ -3931,6 +3935,14 @@ reset = "Show all"
subtitle = "Show or hide categories in the files sidebar."
title = "Sidebar categories"
+[fileSidebar.rename]
+cancel = "Cancel"
+error = "Could not rename the file."
+illegalCharacters = "A file name can't contain \\ / : * ? \" < > |"
+label = "File name"
+save = "Rename"
+title = "Rename file"
+
[filesPage]
addToWorkspace = "Add to workspace"
addToWorkspaceCount = "Add {{count}} to workspace"
@@ -3972,6 +3984,7 @@ downloadAll = "Download all"
downloadVersion = "Download this version"
dropOverlay = "Drop files to upload"
dropOverlaySub = "Files start in Local. Use 'Move to' or 'Save to cloud' to organize them into a folder."
+duplicate = "Duplicate"
file = "File"
fileInfo = "File info"
fileMenu = "File actions"
@@ -4087,6 +4100,8 @@ cloudDeleteFailed_one = "Couldn't delete 1 file from the cloud."
cloudDeleteFailed_other = "Couldn't delete {{count}} files from the cloud."
deleteFolderFailed = "Could not delete folder."
deleteFolderFailedDetail = "Could not delete folder: {{message}}"
+downloadFailed = "Could not download the file."
+duplicateFailed = "Could not duplicate the file."
folderAppearanceFailed = "Could not update folder appearance."
folderAppearanceFailedDetail = "Could not update folder appearance: {{message}}"
moveFilesFailed = "Could not move files."
diff --git a/frontend/editor/src/core/components/filesPage/FileGrid.tsx b/frontend/editor/src/core/components/filesPage/FileGrid.tsx
index e54964c592..7fd2eda133 100644
--- a/frontend/editor/src/core/components/filesPage/FileGrid.tsx
+++ b/frontend/editor/src/core/components/filesPage/FileGrid.tsx
@@ -13,6 +13,7 @@ import DeleteIcon from "@mui/icons-material/Delete";
import HistoryIcon from "@mui/icons-material/History";
import OpenInNewIcon from "@mui/icons-material/OpenInNew";
import DriveFileRenameOutlineIcon from "@mui/icons-material/DriveFileRenameOutline";
+import ContentCopyOutlinedIcon from "@mui/icons-material/ContentCopyOutlined";
import CloudUploadIcon from "@mui/icons-material/CloudUpload";
import UploadFileIcon from "@mui/icons-material/UploadFile";
import CreateNewFolderIcon from "@mui/icons-material/CreateNewFolder";
@@ -36,6 +37,8 @@ import { FolderThumbnail } from "@app/components/filesPage/FolderThumbnail";
import { findFolderIcon } from "@app/components/filesPage/folderIcons";
import { FolderAppearancePicker } from "@app/components/filesPage/FolderAppearancePicker";
import { useLazyThumbnail } from "@app/hooks/useLazyThumbnail";
+import { useFileActionIcons } from "@app/hooks/useFileActionIcons";
+import { useFileActionTerminology } from "@app/hooks/useFileActionTerminology";
import type { FilesPageSortMode } from "@app/contexts/FilesPageContext";
import { OpenInNewWindowMenuItem } from "@app/components/filesPage/OpenInNewWindowMenuItem";
@@ -83,6 +86,12 @@ interface FileGridProps {
onSaveToServer?: (file: StirlingFileStub) => void;
/** Open the version-history modal for a file (only when it has >1 version). */
onVersionHistory?: (file: StirlingFileStub) => void;
+ /** Download a copy (desktop: save a copy). */
+ onDownloadFile?: (file: StirlingFileStub) => void;
+ /** Open the rename dialog for a file. */
+ onRenameFile?: (file: StirlingFileStub) => void;
+ /** Save a second copy of the file into the library. */
+ onDuplicateFile?: (file: StirlingFileStub) => void;
/** When set, the Save to server item renders disabled with this tooltip. */
saveToServerDisabledReason?: string | null;
/** When supplied the list-view column headers become sortable. */
@@ -366,24 +375,21 @@ function EmptyState({
);
}
-function GridView({
- entries,
- selectedFileIds,
- activeWorkspaceFileIds,
- onSelectFile,
- onOpenFolder,
- onOpenFile,
- onMoveFiles,
- onMoveFolder,
- onRenameFolder,
- onDeleteFolder,
- onChangeFolderAppearance,
- onRemoveFiles,
- onPromptMoveFiles,
- onSaveToServer,
- onVersionHistory,
- saveToServerDisabledReason,
-}: FileGridProps) {
+function GridView(props: FileGridProps) {
+ const {
+ entries,
+ selectedFileIds,
+ activeWorkspaceFileIds,
+ onSelectFile,
+ onOpenFolder,
+ onOpenFile,
+ onMoveFiles,
+ onMoveFolder,
+ onRenameFolder,
+ onDeleteFolder,
+ onChangeFolderAppearance,
+ } = props;
+ const menuHandlersFor = useFileMenuHandlers(props);
return (
{entries.map((entry) => {
@@ -424,22 +430,7 @@ function GridView({
onSelectFile(entry.file!.id, e.shiftKey, e.metaKey || e.ctrlKey)
}
onDoubleClick={() => onOpenFile(entry.file!)}
- onRemove={() => onRemoveFiles([entry.file!.id])}
- onMove={() => {
- const target = selectedFileIds.has(entry.file!.id)
- ? Array.from(selectedFileIds)
- : [entry.file!.id];
- onPromptMoveFiles(target);
- }}
- onSaveToServer={
- onSaveToServer ? () => onSaveToServer(entry.file!) : undefined
- }
- onVersionHistory={
- onVersionHistory
- ? () => onVersionHistory(entry.file!)
- : undefined
- }
- saveToServerDisabledReason={saveToServerDisabledReason}
+ {...menuHandlersFor(entry.file)}
/>
);
}
@@ -626,7 +617,222 @@ function PolicyBadges({ fileId }: { fileId: string }) {
return
;
}
-interface FileCardProps {
+/** Per-file actions. Shared verbatim by the grid card and the list row, and
+ * kept in step with the file sidebar's kebab so both surfaces offer the same. */
+interface FileActionsMenuProps {
+ file: StirlingFileStub;
+ triggerRef: React.RefObject
;
+ onOpen: () => void;
+ onMove: () => void;
+ onRemove: () => void;
+ onDownload?: () => void;
+ onRename?: () => void;
+ onDuplicate?: () => void;
+ onSaveToServer?: () => void;
+ onVersionHistory?: () => void;
+ saveToServerDisabledReason?: string | null;
+}
+
+function FileActionsMenu({
+ file,
+ triggerRef,
+ onOpen,
+ onMove,
+ onRemove,
+ onDownload,
+ onRename,
+ onDuplicate,
+ onSaveToServer,
+ onVersionHistory,
+ saveToServerDisabledReason,
+}: FileActionsMenuProps) {
+ const { t } = useTranslation();
+ const terminology = useFileActionTerminology();
+ const DownloadIcon = useFileActionIcons().download;
+ const showSaveToServer =
+ Boolean(onSaveToServer) && file.remoteStorageId == null;
+ const showVersionHistory =
+ Boolean(onVersionHistory) && (file.versionNumber ?? 1) > 1;
+ return (
+
+ );
+}
+
+/** Binds one file's kebab handlers, so grid and list wire them identically. */
+function useFileMenuHandlers(
+ props: FileGridProps,
+): (file: StirlingFileStub) => FileMenuHandlers {
+ const {
+ selectedFileIds,
+ onRemoveFiles,
+ onPromptMoveFiles,
+ onSaveToServer,
+ onVersionHistory,
+ onDownloadFile,
+ onRenameFile,
+ onDuplicateFile,
+ saveToServerDisabledReason,
+ } = props;
+ return (file: StirlingFileStub) => ({
+ onRemove: () => onRemoveFiles([file.id]),
+ // A move acts on the whole selection when this file is part of it.
+ onMove: () =>
+ onPromptMoveFiles(
+ selectedFileIds.has(file.id) ? Array.from(selectedFileIds) : [file.id],
+ ),
+ onDownload: onDownloadFile ? () => onDownloadFile(file) : undefined,
+ onRename: onRenameFile ? () => onRenameFile(file) : undefined,
+ onDuplicate: onDuplicateFile ? () => onDuplicateFile(file) : undefined,
+ onSaveToServer: onSaveToServer ? () => onSaveToServer(file) : undefined,
+ onVersionHistory: onVersionHistory
+ ? () => onVersionHistory(file)
+ : undefined,
+ saveToServerDisabledReason,
+ });
+}
+
+/** Per-file kebab handlers, shared by the card and row wrappers. */
+interface FileMenuHandlers {
+ onRemove: () => void;
+ onMove: () => void;
+ onDownload?: () => void;
+ onRename?: () => void;
+ onDuplicate?: () => void;
+ /** Kebab Save to server; only fires when file is local-only. */
+ onSaveToServer?: () => void;
+ /** Open the version-history modal; shown only when file has >1 version. */
+ onVersionHistory?: () => void;
+ /** When set, the kebab Save to server is disabled with this tooltip. */
+ saveToServerDisabledReason?: string | null;
+}
+
+interface FileCardProps extends FileMenuHandlers {
file: StirlingFileStub;
isSelected: boolean;
isInWorkspace: boolean;
@@ -637,14 +843,6 @@ interface FileCardProps {
multiSelectActive: boolean;
onClick: (e: React.MouseEvent) => void;
onDoubleClick: () => void;
- onRemove: () => void;
- onMove: () => void;
- /** Kebab Save to server; only fires when file is local-only. */
- onSaveToServer?: () => void;
- /** Open the version-history modal; shown only when file has >1 version. */
- onVersionHistory?: () => void;
- /** When set, the kebab Save to server is disabled with this tooltip. */
- saveToServerDisabledReason?: string | null;
}
function FileCard({
@@ -656,11 +854,7 @@ function FileCard({
multiSelectActive,
onClick,
onDoubleClick,
- onRemove,
- onMove,
- onSaveToServer,
- onVersionHistory,
- saveToServerDisabledReason,
+ ...menuHandlers
}: FileCardProps) {
const { t } = useTranslation();
const cardRef = useRef(null);
@@ -790,120 +984,40 @@ function FileCard({
-
+
);
}
-function ListView({
- entries,
- selectedFileIds,
- activeWorkspaceFileIds,
- onSelectFile,
- onSetSelection,
- onOpenFolder,
- onOpenFile,
- onMoveFiles,
- onMoveFolder,
- onRenameFolder,
- onDeleteFolder,
- onSaveToServer,
- onVersionHistory,
- saveToServerDisabledReason,
- onChangeFolderAppearance,
- onRemoveFiles,
- onPromptMoveFiles,
- sortMode,
- onChangeSortMode,
-}: FileGridProps & {
- sortMode?: FilesPageSortMode;
- onChangeSortMode?: (next: FilesPageSortMode) => void;
-}) {
+function ListView(
+ props: FileGridProps & {
+ sortMode?: FilesPageSortMode;
+ onChangeSortMode?: (next: FilesPageSortMode) => void;
+ },
+) {
+ const {
+ entries,
+ selectedFileIds,
+ activeWorkspaceFileIds,
+ onSelectFile,
+ onSetSelection,
+ onOpenFolder,
+ onOpenFile,
+ onMoveFiles,
+ onMoveFolder,
+ onRenameFolder,
+ onDeleteFolder,
+ onChangeFolderAppearance,
+ sortMode,
+ onChangeSortMode,
+ } = props;
+ const menuHandlersFor = useFileMenuHandlers(props);
const { t } = useTranslation();
// Tri-state header checkbox state - computed from current entries.
@@ -1029,22 +1143,7 @@ function ListView({
onSelectFile(entry.file!.id, e.shiftKey, e.metaKey || e.ctrlKey)
}
onOpen={() => onOpenFile(entry.file!)}
- onRemove={() => onRemoveFiles([entry.file!.id])}
- onMove={() => {
- const target = selectedFileIds.has(entry.file!.id)
- ? Array.from(selectedFileIds)
- : [entry.file!.id];
- onPromptMoveFiles(target);
- }}
- onSaveToServer={
- onSaveToServer ? () => onSaveToServer(entry.file!) : undefined
- }
- onVersionHistory={
- onVersionHistory
- ? () => onVersionHistory(entry.file!)
- : undefined
- }
- saveToServerDisabledReason={saveToServerDisabledReason}
+ {...menuHandlersFor(entry.file)}
/>
);
}
@@ -1241,7 +1340,7 @@ function FolderRow({
);
}
-interface FileRowProps {
+interface FileRowProps extends FileMenuHandlers {
file: StirlingFileStub;
isSelected: boolean;
isInWorkspace: boolean;
@@ -1251,14 +1350,6 @@ interface FileRowProps {
multiSelectActive: boolean;
onClick: (e: React.MouseEvent) => void;
onOpen: () => void;
- onRemove: () => void;
- onMove: () => void;
- /** Kebab Save to server; only fires when file is local-only. */
- onSaveToServer?: () => void;
- /** Open the version-history modal; shown only when file has >1 version. */
- onVersionHistory?: () => void;
- /** When set, the kebab Save to server is disabled with this tooltip. */
- saveToServerDisabledReason?: string | null;
}
function FileRow({
@@ -1270,11 +1361,7 @@ function FileRow({
multiSelectActive,
onClick,
onOpen,
- onRemove,
- onMove,
- onSaveToServer,
- onVersionHistory,
- saveToServerDisabledReason,
+ ...menuHandlers
}: FileRowProps) {
const { t } = useTranslation();
const kebabRef = useRef(null);
@@ -1414,91 +1501,12 @@ function FileRow({
{fileSize}
{fileDate}
-
+
);
diff --git a/frontend/editor/src/core/components/filesPage/FileManagerView.tsx b/frontend/editor/src/core/components/filesPage/FileManagerView.tsx
index a0b0c6cfa1..e87b348780 100644
--- a/frontend/editor/src/core/components/filesPage/FileManagerView.tsx
+++ b/frontend/editor/src/core/components/filesPage/FileManagerView.tsx
@@ -71,6 +71,10 @@ import { FolderNameDialog } from "@app/components/filesPage/FolderNameDialog";
import { DeleteFolderDialog } from "@app/components/filesPage/DeleteFolderDialog";
import { DeleteFilesDialog } from "@app/components/filesPage/DeleteFilesDialog";
import { VersionHistoryModal } from "@app/components/filesPage/VersionHistoryModal";
+import { RenameFileDialog } from "@app/components/shared/RenameFileDialog";
+import { duplicateStoredFile } from "@app/utils/duplicateFile";
+import { downloadFileFromStorage } from "@app/utils/downloadUtils";
+import { fileStorage } from "@app/services/fileStorage";
import { materializeServerStubs } from "@app/services/fileSyncService";
import {
FILES_PAGE_DRAG_TYPE,
@@ -794,6 +798,92 @@ export default function FileManagerView() {
[removeFiles],
);
+ // ─── per-file kebab: download / rename / duplicate ───────────────────────
+ // Same actions the file sidebar's kebab offers, so both surfaces match.
+
+ /** Cloud-only rows hold no bytes; pull them local before acting on them. */
+ const localCopyOf = useCallback(
+ async (file: StirlingFileStub): Promise => {
+ const [materialized] = await materializeServerStubs([file], {
+ addFiles: fileActions.addFilesWithOptions,
+ updateStub: fileActions.updateStirlingFileStub,
+ });
+ return materialized ?? null;
+ },
+ [fileActions],
+ );
+
+ const handleDownloadFile = useCallback(
+ async (file: StirlingFileStub) => {
+ try {
+ const local = await localCopyOf(file);
+ if (!local) return;
+ await downloadFileFromStorage(local);
+ } catch (err) {
+ console.error("[FilesPage] Download failed", err);
+ folders.setError(
+ t("filesPage.error.downloadFailed", "Could not download the file."),
+ );
+ }
+ },
+ [localCopyOf, folders, t],
+ );
+
+ const handleDuplicateFile = useCallback(
+ async (file: StirlingFileStub) => {
+ try {
+ const local = await localCopyOf(file);
+ if (!local) return;
+ const copyId = await duplicateStoredFile(
+ local,
+ allFiles.map((f) => f.name),
+ addFiles,
+ );
+ if (!copyId) {
+ throw new Error(`File "${local.name}" not found in storage`);
+ }
+ await refresh();
+ } catch (err) {
+ console.error("[FilesPage] Duplicate failed", err);
+ folders.setError(
+ t("filesPage.error.duplicateFailed", "Could not duplicate the file."),
+ );
+ }
+ },
+ [localCopyOf, allFiles, addFiles, refresh, folders, t],
+ );
+
+ const [renameTarget, setRenameTarget] = useState(
+ null,
+ );
+
+ // The stub name is what the UI and exports read, so a rename is a metadata
+ // write; the workbench copy (if any) is updated in the same breath.
+ const handleConfirmRename = useCallback(
+ async (name: string) => {
+ const file = renameTarget;
+ if (!file) return;
+ const local = await localCopyOf(file);
+ if (!local) return;
+ // quickKey is name|size|lastModified; a stale one would make a re-upload
+ // of the original look like a duplicate of the renamed file.
+ const quickKey = `${name}|${local.size}|${local.lastModified}`;
+ const saved = await fileStorage.updateFileMetadata(local.id, {
+ name,
+ quickKey,
+ });
+ if (!saved) {
+ throw new Error(
+ t("fileSidebar.rename.error", "Could not rename the file."),
+ );
+ }
+ fileActions.updateStirlingFileStub(local.id, { name, quickKey });
+ setRenameTarget(null);
+ await refresh();
+ },
+ [renameTarget, localCopyOf, fileActions, refresh, t],
+ );
+
// ─── derived UI bits ────────────────────────────────────────────────────
const currentFolderRecord = currentFolderId
? (foldersById.get(currentFolderId) ?? null)
@@ -1503,6 +1593,9 @@ export default function FileManagerView() {
onPromptMoveFiles={promptMoveFiles}
onSaveToServer={(file) => setSaveToServerTarget([file])}
onVersionHistory={(file) => setVersionHistoryFile(file)}
+ onDownloadFile={handleDownloadFile}
+ onRenameFile={setRenameTarget}
+ onDuplicateFile={handleDuplicateFile}
saveToServerDisabledReason={saveToServerDisabledReason}
// Center-of-grid CTAs when the empty state shows - same
// handlers the corner header buttons use so behaviour
@@ -1662,6 +1755,14 @@ export default function FileManagerView() {
onConfirm={confirmRemoveFiles}
/>
+ {/* Rename (opened from the card kebab). */}
+ setRenameTarget(null)}
+ onSubmit={handleConfirmRename}
+ />
+
{/* Version journey in a modal (opened from the card kebab). */}
(
const [deleteTarget, setDeleteTarget] = useState(
null,
);
+ // Kebab "Rename" target; drives RenameFileDialog.
+ const [renameTarget, setRenameTarget] = useState(
+ null,
+ );
// Storage gate: only offer Save-to-cloud when the server allows it and
// the user is signed in (guests have no cloud library).
const storageEnabled = config?.storageEnabled === true && !isAnonymous;
@@ -417,6 +425,121 @@ const FileSidebar = forwardRef(
[allFileStubs],
);
+ const warnDataUnavailable = useCallback(() => {
+ alert({
+ alertType: "warning",
+ title: t("fileSidebar.dataLostTitle", "File data is unavailable"),
+ body: t(
+ "fileSidebar.dataLostBody",
+ "This browser lost this file's contents. Upload it again to keep working with it.",
+ ),
+ expandable: false,
+ durationMs: 6000,
+ });
+ }, [t]);
+
+ // Kebab: download a copy (desktop saves via the native dialog). Routed
+ // through the policy wrapper so export policies enforce here too.
+ const handleDownload = useCallback(
+ async (fileId: FileId) => {
+ const stub = allFileStubs.find((s) => s.id === fileId);
+ const file = await fileStorage.getStirlingFile(fileId);
+ if (!file) {
+ warnDataUnavailable();
+ return;
+ }
+ try {
+ await downloadFileWithPolicy({
+ data: file,
+ filename: stub?.name ?? file.name,
+ fileId: fileId as string,
+ });
+ } catch (error) {
+ console.error("[FileSidebar] Download failed:", error);
+ alert({
+ alertType: "error",
+ title: t("fileSidebar.downloadFailed", "Download failed"),
+ body: error instanceof Error ? error.message : String(error),
+ expandable: false,
+ });
+ }
+ },
+ [allFileStubs, warnDataUnavailable, t],
+ );
+
+ // Kebab: copy the file into the library under a free "(copy)" name.
+ const handleDuplicate = useCallback(
+ async (fileId: FileId) => {
+ const stub = allFileStubs.find((s) => s.id === fileId);
+ if (!stub) return;
+ try {
+ const copyId = await duplicateStoredFile(
+ stub,
+ allFileStubs.map((s) => s.name),
+ addFiles,
+ );
+ if (!copyId) {
+ warnDataUnavailable();
+ return;
+ }
+ await refreshStubs();
+ } catch (error) {
+ console.error("[FileSidebar] Duplicate failed:", error);
+ alert({
+ alertType: "error",
+ title: t("fileSidebar.duplicateFailed", "Could not duplicate file"),
+ body: error instanceof Error ? error.message : String(error),
+ expandable: false,
+ });
+ }
+ },
+ [allFileStubs, addFiles, refreshStubs, warnDataUnavailable, t],
+ );
+
+ // Kebab: open the rename dialog for this one file.
+ const handleRename = useCallback(
+ (fileId: FileId) => {
+ const stub = allFileStubs.find((s) => s.id === fileId);
+ if (stub) setRenameTarget(stub);
+ },
+ [allFileStubs],
+ );
+
+ // The stub name is what the UI and exports read, so a rename is a metadata
+ // write - storage first, then the workbench copy if the file is open.
+ const handleConfirmRename = useCallback(
+ async (name: string) => {
+ const stub = renameTarget;
+ if (!stub) return;
+ // quickKey is name|size|lastModified; a stale one would make a re-upload
+ // of the original look like a duplicate of the renamed file.
+ const quickKey = `${name}|${stub.size}|${stub.lastModified}`;
+ const saved = await fileStorage.updateFileMetadata(stub.id, {
+ name,
+ quickKey,
+ });
+ if (!saved) {
+ throw new Error(
+ t("fileSidebar.rename.error", "Could not rename the file."),
+ );
+ }
+ fileActions.updateStirlingFileStub(stub.id, { name, quickKey });
+ setRenameTarget(null);
+ await refreshStubs();
+ },
+ [renameTarget, fileActions, refreshStubs, t],
+ );
+
+ // Desktop-only; a no-op stub on web, where this stays hidden.
+ const { canOpenInNewWindow, openInNewWindow } = useOpenInNewWindow();
+ const handleOpenInNewWindow = useCallback(
+ (fileId: FileId) => {
+ const stub = allFileStubs.find((s) => s.id === fileId);
+ if (stub) openInNewWindow(stub);
+ },
+ [allFileStubs, openInNewWindow],
+ );
+
// Once a pending file lands in state, open it in the viewer.
useEffect(() => {
if (!pendingViewFileId) return;
@@ -773,6 +896,12 @@ const FileSidebar = forwardRef(
onFolderClick={openWatchedFolder}
policies={policyFileBadges.get(stub.id as string) ?? NO_POLICIES}
onDelete={isWatchedFoldersActive ? undefined : handleSidebarDelete}
+ onDownload={handleDownload}
+ onRename={isWatchedFoldersActive ? undefined : handleRename}
+ onDuplicate={isWatchedFoldersActive ? undefined : handleDuplicate}
+ onOpenInNewWindow={
+ canOpenInNewWindow(stub) ? handleOpenInNewWindow : undefined
+ }
onSaveToCloud={isWatchedFoldersActive ? undefined : handleSaveToCloud}
canSaveToCloud={storageEnabled && fileOrigin !== "shared-with-me"}
isUploadedToCloud={fileOrigin === "cloud"}
@@ -1222,6 +1351,14 @@ const FileSidebar = forwardRef(
onChanged={refreshStubs}
/>
+ {/* Kebab "Rename" dialog. */}
+ setRenameTarget(null)}
+ onSubmit={handleConfirmRename}
+ />
+
{/* Cloud-aware delete choice (only opened for cloud-uploaded files). */}
void;
+ /** Download a copy (desktop: save a copy) from the kebab menu. */
+ onDownload?: (fileId: FileId) => void;
+ /** Rename the file from the kebab menu. */
+ onRename?: (fileId: FileId) => void;
+ /** Save a second copy of the file into the library. */
+ onDuplicate?: (fileId: FileId) => void;
+ /** Desktop only: open the file in its own window. Omit where unsupported. */
+ onOpenInNewWindow?: (fileId: FileId) => void;
/** Save to cloud from the kebab menu. */
onSaveToCloud?: (fileId: FileId) => void;
/** Whether the upload-to-server menu item is offered (storage on, signed in). */
@@ -171,6 +184,46 @@ export interface FileItemProps {
const MAX_VISIBLE_FOLDER_TAGS = 2;
+/** One kebab row. `disabledReason`, when set, greys the row out and says why. */
+function FileMenuItem({
+ disabledReason,
+ icon,
+ color,
+ onClick,
+ children,
+}: {
+ disabledReason?: React.ReactNode;
+ icon: React.ReactNode;
+ color?: string;
+ onClick: (e: React.MouseEvent) => void;
+ children: React.ReactNode;
+}) {
+ return (
+
+ {/* Disabled items swallow pointer events, so the tooltip needs a live wrapper. */}
+
+
{
+ e.stopPropagation();
+ onClick(e);
+ }}
+ >
+ {children}
+
+
+
+ );
+}
+
// Memoized: sidebar rows bail out unless THEIR props change, so one file's
// update (e.g. a new version landing) re-renders one row, not the whole list.
export const FileItem = React.memo(function FileItem({
@@ -192,6 +245,10 @@ export const FileItem = React.memo(function FileItem({
policies = [],
primaryLabel,
onDelete,
+ onDownload,
+ onRename,
+ onDuplicate,
+ onOpenInNewWindow,
onSaveToCloud,
canSaveToCloud = false,
isUploadedToCloud = false,
@@ -199,9 +256,14 @@ export const FileItem = React.memo(function FileItem({
hasVersionHistory = false,
}: FileItemProps) {
const { t } = useTranslation();
+ const terminology = useFileActionTerminology();
+ const DownloadIcon = useFileActionIcons().download;
const ext = getFileExtension(name);
const dateLabel = lastModified ? formatFileDate(lastModified) : "";
const typeLabel = ext ? ext.toUpperCase() : "File";
+ const metaLine = [typeLabel, size ? formatFileSize(size) : null, dateLabel]
+ .filter(Boolean)
+ .join(" · ");
const policyEnforcing = policies.some((p) => p.enforcing);
const enforcingTooltip = (action: string): React.ReactNode => (
@@ -220,6 +282,25 @@ export const FileItem = React.memo(function FileItem({
);
+ // Why an action can't run right now: a policy is rewriting the file, or its
+ // bytes are gone. `needsBytes` actions are the ones that read the file.
+ const blockedReason = (
+ action: string,
+ needsBytes = true,
+ ): React.ReactNode | null => {
+ if (policyEnforcing) return enforcingTooltip(action);
+ if (needsBytes && dataUnavailable)
+ return t(
+ "fileSidebar.fileItem.dataLostTooltip",
+ "This browser lost this file's contents. Upload it again to keep working with it.",
+ );
+ return null;
+ };
+
+ const viewerLabel = isViewedInViewer
+ ? t("fileSidebar.fileItem.closeViewer", "Close viewer")
+ : t("fileSidebar.fileItem.openInViewer", "Open in viewer");
+
const visibleFolders = folders.slice(0, MAX_VISIBLE_FOLDER_TAGS);
const overflowFolders = folders.slice(MAX_VISIBLE_FOLDER_TAGS);
@@ -233,6 +314,7 @@ export const FileItem = React.memo(function FileItem({
const itemRef = useRef(null);
const [hoverRect, setHoverRect] = useState(null);
+ const [menuOpened, setMenuOpened] = useState(false);
const handleMouseEnter = useCallback(() => {
setHoverRect(itemRef.current?.getBoundingClientRect() ?? null);
@@ -240,9 +322,10 @@ export const FileItem = React.memo(function FileItem({
const handleMouseLeave = useCallback(() => setHoverRect(null), []);
- // Reactive: tooltip appears as soon as both hover rect and thumbnail are ready
+ // Reactive: tooltip appears as soon as both hover rect and thumbnail are ready.
+ // The kebab suppresses it - two cards floating off one row read as a glitch.
const thumbPos =
- hoverRect && resolvedThumbnail
+ hoverRect && resolvedThumbnail && !menuOpened
? {
top: hoverRect.top + hoverRect.height / 2,
left: hoverRect.right + 10,
@@ -389,11 +472,7 @@ export const FileItem = React.memo(function FileItem({
onEyeClick(fileId, e);
}}
tabIndex={-1}
- aria-label={
- isViewedInViewer
- ? t("fileSidebar.fileItem.closeViewer", "Close viewer")
- : t("fileSidebar.fileItem.openInViewer", "Open in viewer")
- }
+ aria-label={viewerLabel}
>
- {(onDelete ||
- (canSaveToCloud && onSaveToCloud) ||
- (hasVersionHistory && onVersionHistory)) && (
-
- )}
+ >
+
+
+
+ e.stopPropagation()}>
+ {/* Rows truncate long names; the menu header is where the whole
+ name (and the size the row has no space for) is readable. */}
+
+ {name}
+
+ {metaLine}
+
+
+
+
+ ) : (
+
+ )
+ }
+ onClick={(e) => onEyeClick(fileId, e)}
+ >
+ {viewerLabel}
+
+
+ {onOpenInNewWindow && (
+ }
+ onClick={() => onOpenInNewWindow(fileId)}
+ >
+ {t("openInNewWindow", "Open in new window")}
+
+ )}
+
+ {(onDownload || onRename || onDuplicate) && }
+
+ {onDownload && (
+ }
+ onClick={() => onDownload(fileId)}
+ >
+ {terminology.download}
+
+ )}
+
+ {onRename && (
+ }
+ onClick={() => onRename(fileId)}
+ >
+ {t("fileSidebar.fileItem.rename", "Rename")}
+
+ )}
+
+ {onDuplicate && (
+ }
+ onClick={() => onDuplicate(fileId)}
+ >
+ {t("fileSidebar.fileItem.duplicate", "Duplicate")}
+
+ )}
+
+ {((canSaveToCloud && onSaveToCloud) ||
+ (hasVersionHistory && onVersionHistory)) && }
+
+ {canSaveToCloud &&
+ onSaveToCloud &&
+ (() => {
+ const uploadLabel = isUploadedToCloud
+ ? t(
+ "fileSidebar.fileItem.updateOnServer",
+ "Update on server",
+ )
+ : t(
+ "fileSidebar.fileItem.uploadToServer",
+ "Upload to server",
+ );
+ return (
+ }
+ onClick={() => onSaveToCloud(fileId)}
+ >
+ {uploadLabel}
+
+ );
+ })()}
+
+ {hasVersionHistory && onVersionHistory && (
+ }
+ onClick={() => onVersionHistory(fileId)}
+ >
+ {t("fileSidebar.fileItem.versionHistory", "Version history")}
+
+ )}
+
+ {onDelete && (
+ <>
+
+ }
+ onClick={() => onDelete(fileId)}
+ >
+ {t("fileSidebar.fileItem.delete", "Delete")}
+
+ >
+ )}
+
+
diff --git a/frontend/editor/src/core/components/shared/RenameFileDialog.stories.tsx b/frontend/editor/src/core/components/shared/RenameFileDialog.stories.tsx
new file mode 100644
index 0000000000..9413db41b0
--- /dev/null
+++ b/frontend/editor/src/core/components/shared/RenameFileDialog.stories.tsx
@@ -0,0 +1,39 @@
+import type { Meta, StoryObj } from "@storybook/react-vite";
+import { RenameFileDialog } from "@app/components/shared/RenameFileDialog";
+
+const meta = {
+ title: "Shared/RenameFileDialog",
+ component: RenameFileDialog,
+} satisfies Meta;
+export default meta;
+
+type Story = StoryObj;
+
+export const Default: Story = {
+ args: {
+ opened: true,
+ fileName: "Q3 invoice bundle.pdf",
+ onClose: () => {},
+ onSubmit: () => {},
+ },
+};
+
+export const NoExtension: Story = {
+ args: {
+ opened: true,
+ fileName: "scanned-document",
+ onClose: () => {},
+ onSubmit: () => {},
+ },
+};
+
+export const SaveFails: Story = {
+ args: {
+ opened: true,
+ fileName: "contract.pdf",
+ onClose: () => {},
+ onSubmit: () => {
+ throw new Error("Could not rename the file.");
+ },
+ },
+};
diff --git a/frontend/editor/src/core/components/shared/RenameFileDialog.tsx b/frontend/editor/src/core/components/shared/RenameFileDialog.tsx
new file mode 100644
index 0000000000..83e00f6ba3
--- /dev/null
+++ b/frontend/editor/src/core/components/shared/RenameFileDialog.tsx
@@ -0,0 +1,140 @@
+import { useEffect, useState } from "react";
+import { useTranslation } from "react-i18next";
+import { Alert, Group, Modal, Stack, TextInput } from "@mantine/core";
+import ErrorOutlineIcon from "@mui/icons-material/ErrorOutlined";
+
+import { Button } from "@app/ui/Button";
+import { splitFileName } from "@app/utils/fileUtils";
+
+/** Characters Windows/macOS reject in a filename, which is also what a download saves as. */
+const ILLEGAL_NAME_CHARS = /[\\/:*?"<>|]/;
+
+interface RenameFileDialogProps {
+ opened: boolean;
+ /** Current name, including its extension. */
+ fileName: string;
+ onClose: () => void;
+ /** Gets the new full name. Throwing keeps the dialog open with the message. */
+ onSubmit: (name: string) => void | Promise;
+}
+
+/**
+ * Renames one library file. Only the base name is editable - the extension is
+ * shown but fixed, so a rename can't leave the file claiming the wrong type.
+ */
+export function RenameFileDialog({
+ opened,
+ fileName,
+ onClose,
+ onSubmit,
+}: RenameFileDialogProps) {
+ const { t } = useTranslation();
+ const [base, extension] = splitFileName(fileName);
+ const [value, setValue] = useState(base);
+ const [submitting, setSubmitting] = useState(false);
+ const [error, setError] = useState(null);
+
+ useEffect(() => {
+ if (opened) {
+ setValue(base);
+ setSubmitting(false);
+ setError(null);
+ }
+ }, [opened, base]);
+
+ const submit = async () => {
+ const nextBase = value.trim();
+ if (!nextBase) return;
+ if (ILLEGAL_NAME_CHARS.test(nextBase)) {
+ setError(
+ t(
+ "fileSidebar.rename.illegalCharacters",
+ "A file name can't contain \\ / : * ? \" < > |",
+ ),
+ );
+ return;
+ }
+ const nextName = `${nextBase}${extension}`;
+ if (nextName === fileName) {
+ onClose();
+ return;
+ }
+ setSubmitting(true);
+ setError(null);
+ try {
+ await onSubmit(nextName);
+ onClose();
+ } catch (err) {
+ // Stay open on failure: closing would look like the rename worked.
+ setError(
+ err instanceof Error
+ ? err.message
+ : t("fileSidebar.rename.error", "Could not rename the file."),
+ );
+ } finally {
+ setSubmitting(false);
+ }
+ };
+
+ return (
+
+
+ setValue(e.currentTarget.value)}
+ onFocus={(e) => e.currentTarget.select()}
+ onKeyDown={(e) => {
+ if (e.key === "Enter") {
+ e.preventDefault();
+ void submit();
+ }
+ }}
+ maxLength={200}
+ aria-label={t("fileSidebar.rename.label", "File name")}
+ rightSection={
+ extension ? (
+
+ {extension}
+
+ ) : undefined
+ }
+ rightSectionWidth={extension ? extension.length * 8 + 12 : undefined}
+ rightSectionPointerEvents="none"
+ />
+ {error && (
+ }
+ variant="light"
+ role="alert"
+ >
+ {error}
+
+ )}
+
+
+
+
+
+
+ );
+}
diff --git a/frontend/editor/src/core/components/shared/WorkbenchBar.tsx b/frontend/editor/src/core/components/shared/WorkbenchBar.tsx
index 241d658bad..ad38fb1974 100644
--- a/frontend/editor/src/core/components/shared/WorkbenchBar.tsx
+++ b/frontend/editor/src/core/components/shared/WorkbenchBar.tsx
@@ -229,7 +229,8 @@ export default function WorkbenchBar({
try {
const result = await downloadFile({
data: new Blob([buffer], { type: "application/pdf" }),
- filename: fileToExport.name,
+ // Stub name, not File.name: a rename only writes the stub.
+ filename: stub?.name ?? fileToExport.name,
localPath: forceNewFile ? undefined : stub?.localFilePath,
fileId: stub?.id,
});
@@ -281,7 +282,8 @@ export default function WorkbenchBar({
try {
const result = await downloadRaw({
data: enforced[idx],
- filename: file.name,
+ // Stub name, not File.name: a rename only writes the stub.
+ filename: stub?.name ?? file.name,
localPath: forceNewFile ? undefined : stub?.localFilePath,
fileId: stub?.id,
});
diff --git a/frontend/editor/src/core/hooks/useFileHandler.ts b/frontend/editor/src/core/hooks/useFileHandler.ts
index 2f26648fd1..6fcbcb2350 100644
--- a/frontend/editor/src/core/hooks/useFileHandler.ts
+++ b/frontend/editor/src/core/hooks/useFileHandler.ts
@@ -13,6 +13,10 @@ export const useFileHandler = () => {
selectFiles?: boolean;
/** Persist to IDB without dispatching to workspace state. */
skipWorkspaceDispatch?: boolean;
+ /** Defaults to true; false keeps an archive intact (e.g. duplicating one). */
+ autoUnzip?: boolean;
+ /** Skip the upload metric - the file isn't new to the system (e.g. a copy). */
+ skipUploadTracking?: boolean;
} = {},
): Promise => {
// Merge default options with passed options - passed options take precedence
diff --git a/frontend/editor/src/core/tests/stubbed/file-actions-menu.spec.ts b/frontend/editor/src/core/tests/stubbed/file-actions-menu.spec.ts
new file mode 100644
index 0000000000..cafc188462
--- /dev/null
+++ b/frontend/editor/src/core/tests/stubbed/file-actions-menu.spec.ts
@@ -0,0 +1,167 @@
+import path from "path";
+import type { Page } from "@playwright/test";
+import { test, expect } from "@app/tests/helpers/stub-test-base";
+import { uploadFiles } from "@app/tests/helpers/ui-helpers";
+
+// Per-file actions live behind a kebab on two surfaces - the file sidebar and
+// the My Files grid. They must offer the same file actions on both.
+
+const SAMPLE = path.join(import.meta.dirname, "../test-fixtures/sample.pdf");
+
+const rows = (page: Page) => page.locator(".file-sidebar-file-item");
+
+/** Hover the first sidebar row (the kebab only shows on hover) and open it. */
+async function openKebab(page: Page): Promise {
+ const row = rows(page).first();
+ await row.hover();
+ await row.locator(".file-sidebar-kebab-btn").click();
+ await expect(page.getByRole("menu")).toBeVisible();
+}
+
+test("the kebab lists the file's actions under its full name", async ({
+ page,
+}) => {
+ await uploadFiles(page, SAMPLE);
+ await openKebab(page);
+
+ const menu = page.getByRole("menu");
+ await expect(menu.locator(".file-sidebar-kebab-header-name")).toHaveText(
+ "sample.pdf",
+ );
+ // Type · size · date - the row itself has no space for the size.
+ await expect(menu.locator(".file-sidebar-kebab-header-meta")).toContainText(
+ "PDF",
+ );
+ // A lone upload lands in the viewer, so the toggle offers the way out.
+ await expect(
+ menu.getByRole("menuitem", { name: "Close viewer" }),
+ ).toBeVisible();
+ await expect(menu.getByRole("menuitem", { name: "Download" })).toBeVisible();
+ await expect(menu.getByRole("menuitem", { name: "Rename" })).toBeVisible();
+ await expect(menu.getByRole("menuitem", { name: "Duplicate" })).toBeVisible();
+ await expect(menu.getByRole("menuitem", { name: "Delete" })).toBeVisible();
+});
+
+test("Download saves the file under its current name", async ({ page }) => {
+ await uploadFiles(page, SAMPLE);
+ await openKebab(page);
+
+ const download = page.waitForEvent("download");
+ await page.getByRole("menuitem", { name: "Download" }).click();
+ expect((await download).suggestedFilename()).toBe("sample.pdf");
+});
+
+test("Rename updates the row and survives a reload", async ({ page }) => {
+ await uploadFiles(page, SAMPLE);
+ await openKebab(page);
+ await page.getByRole("menuitem", { name: "Rename" }).click();
+
+ // Only the base name is editable; the extension is re-applied on submit.
+ const input = page.getByLabel("File name");
+ await expect(input).toHaveValue("sample");
+ await input.fill("quarterly report");
+ await page.getByRole("button", { name: "Rename" }).click();
+
+ await expect(rows(page).first()).toContainText("quarterly report.pdf");
+
+ // The name is metadata in IndexedDB, so it must outlive the page.
+ await page.reload();
+ await expect(rows(page).first()).toContainText("quarterly report.pdf");
+});
+
+test("Duplicate adds a copy to the library", async ({ page }) => {
+ await uploadFiles(page, SAMPLE);
+ await openKebab(page);
+ await page.getByRole("menuitem", { name: "Duplicate" }).click();
+
+ await expect(rows(page)).toHaveCount(2);
+ await expect(rows(page).filter({ hasText: "sample (copy).pdf" })).toHaveCount(
+ 1,
+ );
+});
+
+test("a duplicate inherits the original's classification", async ({ page }) => {
+ // The copy is byte-identical, so it must land in the same category group -
+ // it inherits the label rather than waiting on the idle backfill to re-parse.
+ await uploadFiles(
+ page,
+ path.join(
+ import.meta.dirname,
+ "../test-fixtures/classification/classified_invoice.pdf",
+ ),
+ );
+ const financial = page
+ .locator(".file-sidebar-group")
+ .filter({ hasText: "Financial" });
+ await expect(financial).toBeVisible({ timeout: 15_000 });
+
+ await openKebab(page);
+ await page.getByRole("menuitem", { name: "Duplicate" }).click();
+
+ // The copy carries the label straight away - its row shows the label chip...
+ const copy = rows(page)
+ .filter({ hasText: "classified_invoice (copy).pdf" })
+ .first();
+ await expect(copy).toContainText("Invoice", { timeout: 5_000 });
+ // ...and it counts towards the same category group.
+ await expect(financial.locator(".file-sidebar-group-count")).toHaveText("2");
+});
+
+// ─── My Files grid: the same actions, same behaviour ────────────────────────
+
+const cards = (page: Page) => page.locator(".files-page-card:not(.is-folder)");
+
+/** Upload a file, cross to My Files, and open the card's kebab. */
+async function openCardKebab(page: Page): Promise {
+ await uploadFiles(page, SAMPLE);
+ await page.getByTestId("my-files-button").click();
+ const card = cards(page).filter({ hasText: "sample.pdf" }).first();
+ await expect(card).toBeVisible();
+ await card.getByRole("button", { name: /File actions/i }).click();
+ await expect(page.getByRole("menu")).toBeVisible();
+}
+
+test("My Files offers the same file actions as the sidebar", async ({
+ page,
+}) => {
+ await openCardKebab(page);
+
+ const menu = page.getByRole("menu");
+ await expect(
+ menu.getByRole("menuitem", { name: "Add to workspace" }),
+ ).toBeVisible();
+ await expect(menu.getByRole("menuitem", { name: "Move to…" })).toBeVisible();
+ await expect(menu.getByRole("menuitem", { name: "Download" })).toBeVisible();
+ await expect(menu.getByRole("menuitem", { name: "Rename" })).toBeVisible();
+ await expect(menu.getByRole("menuitem", { name: "Duplicate" })).toBeVisible();
+ await expect(menu.getByRole("menuitem", { name: "Delete" })).toBeVisible();
+});
+
+test("My Files Download saves the file under its current name", async ({
+ page,
+}) => {
+ await openCardKebab(page);
+
+ const download = page.waitForEvent("download");
+ await page.getByRole("menuitem", { name: "Download" }).click();
+ expect((await download).suggestedFilename()).toBe("sample.pdf");
+});
+
+test("My Files Rename updates the card", async ({ page }) => {
+ await openCardKebab(page);
+ await page.getByRole("menuitem", { name: "Rename" }).click();
+
+ await page.getByLabel("File name").fill("statement");
+ await page.getByRole("button", { name: "Rename" }).click();
+
+ await expect(cards(page).filter({ hasText: "statement.pdf" })).toHaveCount(1);
+});
+
+test("My Files Duplicate adds a copy", async ({ page }) => {
+ await openCardKebab(page);
+ await page.getByRole("menuitem", { name: "Duplicate" }).click();
+
+ await expect(
+ cards(page).filter({ hasText: "sample (copy).pdf" }),
+ ).toHaveCount(1);
+});
diff --git a/frontend/editor/src/core/utils/duplicateFile.test.ts b/frontend/editor/src/core/utils/duplicateFile.test.ts
new file mode 100644
index 0000000000..88c7cce686
--- /dev/null
+++ b/frontend/editor/src/core/utils/duplicateFile.test.ts
@@ -0,0 +1,130 @@
+import { describe, it, expect, vi, beforeEach } from "vitest";
+import type { StirlingFile, StirlingFileStub } from "@app/types/fileContext";
+import type { FileId } from "@app/types/file";
+import type { FolderId } from "@app/types/folder";
+
+const getStirlingFile = vi.fn();
+const updateFileMetadata = vi.fn();
+
+vi.mock("@app/services/fileStorage", () => ({
+ fileStorage: {
+ getStirlingFile: (...args: unknown[]) => getStirlingFile(...args),
+ updateFileMetadata: (...args: unknown[]) => updateFileMetadata(...args),
+ },
+}));
+
+const { copyNameFor, duplicateStoredFile } =
+ await import("@app/utils/duplicateFile");
+
+/**
+ * A duplicate is byte-identical to its source, so everything derived from those
+ * bytes (classification labels, thumbnail) and its place in the library (folder)
+ * is inherited rather than re-derived. These lock that contract.
+ */
+
+const stub = (extra: Partial = {}): StirlingFileStub =>
+ ({
+ id: "src-id" as FileId,
+ name: "report.pdf",
+ size: 10,
+ type: "application/pdf",
+ lastModified: 1,
+ ...extra,
+ }) as StirlingFileStub;
+
+const asStirlingFile = (name: string): StirlingFile =>
+ Object.assign(new File(["%PDF-1.7"], name, { type: "application/pdf" }), {
+ fileId: "new-id" as FileId,
+ quickKey: "k",
+ }) as StirlingFile;
+
+type AddFilesOptions = {
+ selectFiles?: boolean;
+ skipWorkspaceDispatch?: boolean;
+ autoUnzip?: boolean;
+ skipUploadTracking?: boolean;
+};
+const addFiles = vi.fn<
+ (files: File[], options: AddFilesOptions) => Promise
+>(async () => [asStirlingFile("report (copy).pdf")]);
+
+beforeEach(() => {
+ vi.clearAllMocks();
+ getStirlingFile.mockResolvedValue(asStirlingFile("report.pdf"));
+ updateFileMetadata.mockResolvedValue(true);
+ addFiles.mockResolvedValue([asStirlingFile("report (copy).pdf")]);
+});
+
+describe("copyNameFor", () => {
+ it("keeps the extension and counts up until the name is free", () => {
+ expect(copyNameFor("report.pdf", [])).toBe("report (copy).pdf");
+ expect(copyNameFor("report.pdf", ["report (copy).pdf"])).toBe(
+ "report (copy 2).pdf",
+ );
+ expect(
+ copyNameFor("report.pdf", ["report (copy).pdf", "report (copy 2).pdf"]),
+ ).toBe("report (copy 3).pdf");
+ });
+
+ it("handles a name with no extension", () => {
+ expect(copyNameFor("scan", [])).toBe("scan (copy)");
+ });
+});
+
+describe("duplicateStoredFile", () => {
+ it("inherits labels, folder and a persisted thumbnail", async () => {
+ const id = await duplicateStoredFile(
+ stub({
+ classificationLabels: ["invoice"],
+ folderId: "folder-1" as FolderId,
+ thumbnailUrl: "data:image/png;base64,AAA",
+ }),
+ [],
+ addFiles,
+ );
+
+ expect(id).toBe("new-id");
+ const [, updates] = updateFileMetadata.mock.calls[0];
+ expect(updates.classificationLabels).toEqual(["invoice"]);
+ expect(updates.folderId).toBe("folder-1");
+ expect(updates.thumbnail).toBe("data:image/png;base64,AAA");
+ expect(updates.thumbnailStoredAt).toEqual(expect.any(Number));
+ });
+
+ it("drops a blob: thumbnail - it would be dead on the next load", async () => {
+ await duplicateStoredFile(
+ stub({ classificationLabels: ["invoice"], thumbnailUrl: "blob:abc" }),
+ [],
+ addFiles,
+ );
+
+ const [, updates] = updateFileMetadata.mock.calls[0];
+ expect(updates).not.toHaveProperty("thumbnail");
+ });
+
+ it("writes nothing when the source has no derived metadata", async () => {
+ await duplicateStoredFile(stub(), [], addFiles);
+
+ expect(updateFileMetadata).not.toHaveBeenCalled();
+ });
+
+ it("copies the library entry without touching the workbench or metrics", async () => {
+ await duplicateStoredFile(stub(), ["report (copy).pdf"], addFiles);
+
+ const [files, options] = addFiles.mock.calls[0];
+ expect(files[0].name).toBe("report (copy 2).pdf");
+ expect(options).toMatchObject({
+ selectFiles: false,
+ skipWorkspaceDispatch: true,
+ autoUnzip: false,
+ skipUploadTracking: true,
+ });
+ });
+
+ it("returns null when the source bytes are gone", async () => {
+ getStirlingFile.mockResolvedValue(null);
+
+ expect(await duplicateStoredFile(stub(), [], addFiles)).toBeNull();
+ expect(addFiles).not.toHaveBeenCalled();
+ });
+});
diff --git a/frontend/editor/src/core/utils/duplicateFile.ts b/frontend/editor/src/core/utils/duplicateFile.ts
new file mode 100644
index 0000000000..5bad75b513
--- /dev/null
+++ b/frontend/editor/src/core/utils/duplicateFile.ts
@@ -0,0 +1,85 @@
+import type { StirlingFile, StirlingFileStub } from "@app/types/fileContext";
+import type { FileId } from "@app/types/file";
+import { fileStorage } from "@app/services/fileStorage";
+import { splitFileName } from "@app/utils/fileUtils";
+
+/** The subset of `useFileHandler().addFiles` a duplicate needs. */
+type AddFilesFn = (
+ files: File[],
+ options: {
+ selectFiles?: boolean;
+ skipWorkspaceDispatch?: boolean;
+ autoUnzip?: boolean;
+ skipUploadTracking?: boolean;
+ },
+) => Promise;
+
+/** "report.pdf" → "report (copy).pdf", counting up until the name is free. */
+export function copyNameFor(name: string, taken: Iterable): string {
+ const [base, extension] = splitFileName(name);
+ const used = new Set(taken);
+ let candidate = `${base} (copy)${extension}`;
+ for (let n = 2; used.has(candidate); n++) {
+ candidate = `${base} (copy ${n})${extension}`;
+ }
+ return candidate;
+}
+
+/**
+ * Copies a stored file into the library under a free "(copy)" name.
+ *
+ * The copy stays out of the workbench - it's an archive of the current bytes,
+ * not a file the user asked to work on. That skips the ingest side-effects that
+ * hang off the workspace dispatch, so the derived metadata is inherited from
+ * the source instead: the bytes are identical, so its classification labels and
+ * thumbnail are too, and re-deriving them would only re-parse the same PDF. The
+ * copy also lands in the source's folder rather than back at the root.
+ *
+ * @returns the new file's id, or null if the source has no readable bytes.
+ */
+export async function duplicateStoredFile(
+ stub: StirlingFileStub,
+ existingNames: Iterable,
+ addFiles: AddFilesFn,
+): Promise {
+ const source = await fileStorage.getStirlingFile(stub.id);
+ if (!source) return null;
+
+ const [copy] = await addFiles(
+ [
+ new File([source], copyNameFor(stub.name, existingNames), {
+ type: source.type,
+ }),
+ ],
+ {
+ selectFiles: false,
+ skipWorkspaceDispatch: true,
+ // Duplicating an archive must yield one copy, not its contents scattered
+ // across the library.
+ autoUnzip: false,
+ // A local copy is not a new document entering the system.
+ skipUploadTracking: true,
+ },
+ );
+ if (!copy) return null;
+
+ const inherited: Parameters[1] = {};
+ if (stub.classificationLabels) {
+ inherited.classificationLabels = stub.classificationLabels;
+ }
+ // A copy belongs beside its original. Safe to set on a local-only file: the
+ // server is authoritative for folderId only on files it actually holds.
+ if (stub.folderId) {
+ inherited.folderId = stub.folderId;
+ }
+ // Blob URLs die with the session, so only a persisted thumbnail is worth
+ // carrying over; without one the row falls back to lazy regeneration.
+ if (stub.thumbnailUrl && !stub.thumbnailUrl.startsWith("blob:")) {
+ inherited.thumbnail = stub.thumbnailUrl;
+ inherited.thumbnailStoredAt = Date.now();
+ }
+ if (Object.keys(inherited).length > 0) {
+ await fileStorage.updateFileMetadata(copy.fileId, inherited);
+ }
+ return copy.fileId;
+}
diff --git a/frontend/editor/src/core/utils/fileUtils.ts b/frontend/editor/src/core/utils/fileUtils.ts
index 6a256a9073..13002db52e 100644
--- a/frontend/editor/src/core/utils/fileUtils.ts
+++ b/frontend/editor/src/core/utils/fileUtils.ts
@@ -73,6 +73,17 @@ export function getFilenameWithoutExtension(
return preserveCase ? withoutExtension : withoutExtension.toLowerCase();
}
+/**
+ * Splits a filename into its base and extension, keeping the dot on the
+ * extension so `base + extension` round-trips. A name with no extension (or a
+ * leading-dot name like ".env") gets an empty extension.
+ * @example splitFileName('report.pdf') // ['report', '.pdf']
+ */
+export function splitFileName(name: string): [string, string] {
+ const dot = name.lastIndexOf(".");
+ return dot > 0 ? [name.slice(0, dot), name.slice(dot)] : [name, ""];
+}
+
/**
* Checks if a file is a PDF based on extension and MIME type
* @param file - File or file-like object with name and type properties
From 96a00cebd18ba703f7c5719fa348d31885cd6543 Mon Sep 17 00:00:00 2001
From: Anthony Stirling <77850077+Frooodle@users.noreply.github.com>
Date: Thu, 20 Aug 2026 12:00:03 +0000
Subject: [PATCH 02/52] Pdf ua converter testing (#7301)
# Description of Changes
Adds a PDF/UA converter, an accessibility report, and PDF/A conformance
level A.
**New: `POST /api/v1/convert/pdf/ua`** (Convert tool, "PDF/UA" target).
Tags an untagged PDF, marks
decorative content as artifacts, embeds missing fonts and applies the
document-level PDF/UA
requirements (title, language, tab order, form-field descriptions), then
validates with veraPDF. The
`pdfuaid` declaration is written only if validation passes, so a
returned file never claims more
than it delivers; response headers report whether it was declared, how
many checks still fail and
how many images still need a description.
**New: `POST /api/v1/security/accessibility-report`.** Reports what
fails, what the converter can fix
on its own, what needs a person, and lists the figures needing a
description with the keys the
conversion accepts back. Read-only; does not modify the file. Capped at
100 MB / 2000 pages and
weighted `LARGE_WEIGHT`, since it runs a full veraPDF pass plus the
converter's layout analysis over
every page.
**PDF/A level A.** `pdfa-1a`, `pdfa-2a` and `pdfa-3a` output formats on
the existing
`/api/v1/convert/pdf/pdfa` endpoint. Level A is level B plus tagging, so
the document is tagged
after Ghostscript (which discards any structure tree it is given) and
the level A claim is written
only if veraPDF agrees. Optional `pdfUa=true` additionally declares
PDF/UA alongside PDF/A, again
only if it validates.
Honesty rules the implementation holds to:
- **Never claim a level that was not reached.** If tagging fails, the
file is returned at level B and
is named `_PDFA-2b.pdf`, not `_PDFA-2a.pdf`. With `strict=true` the
request fails outright rather
than returning a level B file against a level A request, and a level B
pass no longer satisfies a
strict level A request.
- **Never relabel a document's language.** The requested language
(default `en-GB`) is applied only
when the document declares none; a French PDF stays French unless the
caller sets
`overrideLanguage`, and ignoring a requested language is reported as a
warning.
- **Never invent alternative text.** Descriptions come from the caller.
The Convert panel can list
the images needing one (via the report endpoint) and send them back per
figure; any image left
undescribed blocks the conformance claim rather than being papered over.
- **Never certify hidden content.** Marking images decorative, or
suppressing text that could not be
tagged reliably, withdraws the claim instead of passing the checker by
hiding content.
PDF/UA-1 and PDF/UA-2 are both offered; UA-2 raises the file to PDF 2.0
and namespaces the structure
tree, and its test asserts conformance rather than merely reporting it.
Convert steps saved in Automations/Pipelines round-trip their PDF/UA
settings (profile, language,
override, title, font embedding, descriptions).
---
## Checklist
### General
- [ ] I have read the [Contribution
Guidelines](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/CONTRIBUTING.md)
- [ ] I have read the [Stirling-PDF Developer
Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/DeveloperGuide.md)
(if applicable)
- [ ] I have read the [How to add new languages to
Stirling-PDF](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md)
(if applicable)
- [ ] I have performed a self-review of my own code
- [ ] My changes generate no new warnings
### Documentation
- [ ] I have updated relevant docs on [Stirling-PDF's doc
repo](https://github.com/Stirling-Tools/Stirling-Tools.github.io/blob/main/docs/)
(if functionality has heavily changed)
- [ ] I have read the section [Add New Translation
Tags](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md#add-new-translation-tags)
(for new translation tags only)
### Translations (if applicable)
- [ ] I ran
[`scripts/counter_translation.py`](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/docs/counter_translation.md)
### UI Changes (if applicable)
- [ ] Screenshots or videos demonstrating the UI changes are attached
(e.g., as comments or direct attachments in the PR)
### Testing (if applicable)
- [ ] I have run `task check` to verify linters, typechecks, and tests
pass
- [ ] I have tested my changes locally. Refer to the [Testing
Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/DeveloperGuide.md#7-testing)
for more details.
---
.../SPDF/config/EndpointConfiguration.java | 19 +-
.../service/PdfaLevelAServiceInterface.java | 22 +
.../config/EndpointConfigurationGapTest.java | 32 +-
.../api/converters/ConvertPDFToPDFA.java | 171 +++-
.../api/converters/PdfToPdfARequest.java | 12 +-
.../software/SPDF/service/VeraPDFService.java | 2 +
.../converters/ConvertPDFToPDFAGapTest.java | 136 ++-
.../converters/ConvertPDFToPDFAMoreTest.java | 5 +-
.../VeraPDFServicePdfaFixtureTest.java | 17 +-
app/proprietary/build.gradle | 9 +
.../api/converters/ConvertPdfToPdfUa.java | 176 ++++
.../AccessibilityReportController.java | 67 ++
.../api/converters/PdfToPdfUaRequest.java | 73 ++
.../model/api/ua/AccessibilityIssue.java | 38 +
.../model/api/ua/AccessibilityReport.java | 63 ++
.../api/ua/AccessibilityReportRequest.java | 19 +
.../model/api/ua/FigureDescriptor.java | 18 +
.../model/api/ua/PdfUaConversionOutcome.java | 26 +
.../model/api/ua/UaValidationResult.java | 18 +
.../proprietary/pdf/ua/ArtifactType.java | 23 +
.../software/proprietary/pdf/ua/BBox.java | 48 +
.../proprietary/pdf/ua/DocumentStructure.java | 84 ++
.../proprietary/pdf/ua/LayoutAnalyzer.java | 831 ++++++++++++++++++
.../proprietary/pdf/ua/MarkableOp.java | 44 +
.../pdf/ua/MarkedContentInjector.java | 284 ++++++
.../proprietary/pdf/ua/PageContent.java | 33 +
.../pdf/ua/PdfUaIdentificationSchema.java | 47 +
.../pdf/ua/PdfUaMetadataWriter.java | 224 +++++
.../proprietary/pdf/ua/PdfUaProfile.java | 47 +
.../proprietary/pdf/ua/PdfUaTagger.java | 303 +++++++
.../proprietary/pdf/ua/SourceFacts.java | 59 ++
.../proprietary/pdf/ua/StructBlock.java | 134 +++
.../proprietary/pdf/ua/StructTreeWriter.java | 295 +++++++
.../proprietary/pdf/ua/StructType.java | 62 ++
.../pdf/ua/TaggedContentExtractor.java | 630 +++++++++++++
.../proprietary/pdf/ua/TaggingOptions.java | 63 ++
.../proprietary/pdf/ua/TaggingResult.java | 42 +
.../proprietary/pdf/ua/TextLineInfo.java | 42 +
.../software/proprietary/pdf/ua/WordInfo.java | 18 +
.../service/ua/AccessibilityAuditService.java | 187 ++++
.../service/ua/FontEmbeddingService.java | 254 ++++++
.../service/ua/PdfUaConversionService.java | 251 ++++++
.../service/ua/PdfUaValidationService.java | 245 ++++++
.../service/ua/PdfaAccessibilityService.java | 297 +++++++
.../pdf/ua/LayoutAnalyzerTest.java | 286 ++++++
.../pdf/ua/MarkedContentInjectorTest.java | 164 ++++
.../pdf/ua/MarkedContentSafetyTest.java | 193 ++++
.../pdf/ua/PdfUaFormAndDeclarationTest.java | 171 ++++
.../proprietary/pdf/ua/PdfUaLanguageTest.java | 79 ++
.../pdf/ua/PdfUaMetadataWriterTest.java | 137 +++
.../proprietary/pdf/ua/PdfUaModelTest.java | 160 ++++
.../pdf/ua/VectorAndHeadingTest.java | 155 ++++
.../service/ua/AltTextRoundTripTest.java | 115 +++
.../service/ua/PdfUa2ProfileTest.java | 92 ++
.../service/ua/PdfUaBenchmarkTest.java | 341 +++++++
.../ua/PdfUaConversionIntegrationTest.java | 231 +++++
.../service/ua/PdfUaHardeningTest.java | 322 +++++++
.../service/ua/PdfUaHttpEndpointTest.java | 183 ++++
.../service/ua/PdfUaRealCorpusTest.java | 249 ++++++
.../service/ua/PdfUaSampleDumpTest.java | 103 +++
.../service/ua/PdfUaServicesTest.java | 319 +++++++
.../service/ua/PdfUaTestDocuments.java | 390 ++++++++
.../service/ua/PdfaLevelATest.java | 202 +++++
.../TaggedContentExtractorRealFilesTest.java | 99 +++
engine/src/stirling/models/tool_io.py | 4 +
engine/src/stirling/models/tool_models.py | 89 ++
.../public/locales/en-US/translation.toml | 23 +
.../tools/convert/ConvertSettings.tsx | 15 +
.../ConvertToPdfUaSettings.selection.test.tsx | 149 ++++
.../convert/ConvertToPdfUaSettings.test.ts | 34 +
.../tools/convert/ConvertToPdfUaSettings.tsx | 280 ++++++
.../tools/convert/ConvertToPdfaSettings.tsx | 11 +
.../src/core/constants/convertConstants.ts | 6 +
.../tools/convert/convertPdfUaAltText.test.ts | 92 ++
.../tools/convert/useConvertOperation.ts | 55 +-
.../tools/convert/useConvertParameters.ts | 17 +
.../hooks/tools/shared/toolAutomation.test.ts | 46 +
.../tests/convert/ConvertIntegration.test.tsx | 96 ++
.../editor/src/core/types/toolApiTypes.ts | 53 ++
frontend/editor/src/core/types/toolIO.ts | 10 +
80 files changed, 10373 insertions(+), 68 deletions(-)
create mode 100644 app/common/src/main/java/stirling/software/common/service/PdfaLevelAServiceInterface.java
create mode 100644 app/proprietary/src/main/java/stirling/software/proprietary/controller/api/converters/ConvertPdfToPdfUa.java
create mode 100644 app/proprietary/src/main/java/stirling/software/proprietary/controller/api/security/AccessibilityReportController.java
create mode 100644 app/proprietary/src/main/java/stirling/software/proprietary/model/api/converters/PdfToPdfUaRequest.java
create mode 100644 app/proprietary/src/main/java/stirling/software/proprietary/model/api/ua/AccessibilityIssue.java
create mode 100644 app/proprietary/src/main/java/stirling/software/proprietary/model/api/ua/AccessibilityReport.java
create mode 100644 app/proprietary/src/main/java/stirling/software/proprietary/model/api/ua/AccessibilityReportRequest.java
create mode 100644 app/proprietary/src/main/java/stirling/software/proprietary/model/api/ua/FigureDescriptor.java
create mode 100644 app/proprietary/src/main/java/stirling/software/proprietary/model/api/ua/PdfUaConversionOutcome.java
create mode 100644 app/proprietary/src/main/java/stirling/software/proprietary/model/api/ua/UaValidationResult.java
create mode 100644 app/proprietary/src/main/java/stirling/software/proprietary/pdf/ua/ArtifactType.java
create mode 100644 app/proprietary/src/main/java/stirling/software/proprietary/pdf/ua/BBox.java
create mode 100644 app/proprietary/src/main/java/stirling/software/proprietary/pdf/ua/DocumentStructure.java
create mode 100644 app/proprietary/src/main/java/stirling/software/proprietary/pdf/ua/LayoutAnalyzer.java
create mode 100644 app/proprietary/src/main/java/stirling/software/proprietary/pdf/ua/MarkableOp.java
create mode 100644 app/proprietary/src/main/java/stirling/software/proprietary/pdf/ua/MarkedContentInjector.java
create mode 100644 app/proprietary/src/main/java/stirling/software/proprietary/pdf/ua/PageContent.java
create mode 100644 app/proprietary/src/main/java/stirling/software/proprietary/pdf/ua/PdfUaIdentificationSchema.java
create mode 100644 app/proprietary/src/main/java/stirling/software/proprietary/pdf/ua/PdfUaMetadataWriter.java
create mode 100644 app/proprietary/src/main/java/stirling/software/proprietary/pdf/ua/PdfUaProfile.java
create mode 100644 app/proprietary/src/main/java/stirling/software/proprietary/pdf/ua/PdfUaTagger.java
create mode 100644 app/proprietary/src/main/java/stirling/software/proprietary/pdf/ua/SourceFacts.java
create mode 100644 app/proprietary/src/main/java/stirling/software/proprietary/pdf/ua/StructBlock.java
create mode 100644 app/proprietary/src/main/java/stirling/software/proprietary/pdf/ua/StructTreeWriter.java
create mode 100644 app/proprietary/src/main/java/stirling/software/proprietary/pdf/ua/StructType.java
create mode 100644 app/proprietary/src/main/java/stirling/software/proprietary/pdf/ua/TaggedContentExtractor.java
create mode 100644 app/proprietary/src/main/java/stirling/software/proprietary/pdf/ua/TaggingOptions.java
create mode 100644 app/proprietary/src/main/java/stirling/software/proprietary/pdf/ua/TaggingResult.java
create mode 100644 app/proprietary/src/main/java/stirling/software/proprietary/pdf/ua/TextLineInfo.java
create mode 100644 app/proprietary/src/main/java/stirling/software/proprietary/pdf/ua/WordInfo.java
create mode 100644 app/proprietary/src/main/java/stirling/software/proprietary/service/ua/AccessibilityAuditService.java
create mode 100644 app/proprietary/src/main/java/stirling/software/proprietary/service/ua/FontEmbeddingService.java
create mode 100644 app/proprietary/src/main/java/stirling/software/proprietary/service/ua/PdfUaConversionService.java
create mode 100644 app/proprietary/src/main/java/stirling/software/proprietary/service/ua/PdfUaValidationService.java
create mode 100644 app/proprietary/src/main/java/stirling/software/proprietary/service/ua/PdfaAccessibilityService.java
create mode 100644 app/proprietary/src/test/java/stirling/software/proprietary/pdf/ua/LayoutAnalyzerTest.java
create mode 100644 app/proprietary/src/test/java/stirling/software/proprietary/pdf/ua/MarkedContentInjectorTest.java
create mode 100644 app/proprietary/src/test/java/stirling/software/proprietary/pdf/ua/MarkedContentSafetyTest.java
create mode 100644 app/proprietary/src/test/java/stirling/software/proprietary/pdf/ua/PdfUaFormAndDeclarationTest.java
create mode 100644 app/proprietary/src/test/java/stirling/software/proprietary/pdf/ua/PdfUaLanguageTest.java
create mode 100644 app/proprietary/src/test/java/stirling/software/proprietary/pdf/ua/PdfUaMetadataWriterTest.java
create mode 100644 app/proprietary/src/test/java/stirling/software/proprietary/pdf/ua/PdfUaModelTest.java
create mode 100644 app/proprietary/src/test/java/stirling/software/proprietary/pdf/ua/VectorAndHeadingTest.java
create mode 100644 app/proprietary/src/test/java/stirling/software/proprietary/service/ua/AltTextRoundTripTest.java
create mode 100644 app/proprietary/src/test/java/stirling/software/proprietary/service/ua/PdfUa2ProfileTest.java
create mode 100644 app/proprietary/src/test/java/stirling/software/proprietary/service/ua/PdfUaBenchmarkTest.java
create mode 100644 app/proprietary/src/test/java/stirling/software/proprietary/service/ua/PdfUaConversionIntegrationTest.java
create mode 100644 app/proprietary/src/test/java/stirling/software/proprietary/service/ua/PdfUaHardeningTest.java
create mode 100644 app/proprietary/src/test/java/stirling/software/proprietary/service/ua/PdfUaHttpEndpointTest.java
create mode 100644 app/proprietary/src/test/java/stirling/software/proprietary/service/ua/PdfUaRealCorpusTest.java
create mode 100644 app/proprietary/src/test/java/stirling/software/proprietary/service/ua/PdfUaSampleDumpTest.java
create mode 100644 app/proprietary/src/test/java/stirling/software/proprietary/service/ua/PdfUaServicesTest.java
create mode 100644 app/proprietary/src/test/java/stirling/software/proprietary/service/ua/PdfUaTestDocuments.java
create mode 100644 app/proprietary/src/test/java/stirling/software/proprietary/service/ua/PdfaLevelATest.java
create mode 100644 app/proprietary/src/test/java/stirling/software/proprietary/service/ua/TaggedContentExtractorRealFilesTest.java
create mode 100644 frontend/editor/src/core/components/tools/convert/ConvertToPdfUaSettings.selection.test.tsx
create mode 100644 frontend/editor/src/core/components/tools/convert/ConvertToPdfUaSettings.test.ts
create mode 100644 frontend/editor/src/core/components/tools/convert/ConvertToPdfUaSettings.tsx
create mode 100644 frontend/editor/src/core/hooks/tools/convert/convertPdfUaAltText.test.ts
diff --git a/app/common/src/main/java/stirling/software/SPDF/config/EndpointConfiguration.java b/app/common/src/main/java/stirling/software/SPDF/config/EndpointConfiguration.java
index ff1a880010..5e8f7fe336 100644
--- a/app/common/src/main/java/stirling/software/SPDF/config/EndpointConfiguration.java
+++ b/app/common/src/main/java/stirling/software/SPDF/config/EndpointConfiguration.java
@@ -6,6 +6,7 @@ import java.util.Map;
import java.util.Set;
import java.util.concurrent.ConcurrentHashMap;
+import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.stereotype.Service;
@@ -13,6 +14,7 @@ import lombok.Getter;
import lombok.extern.slf4j.Slf4j;
import stirling.software.common.model.ApplicationProperties;
+import stirling.software.common.service.PdfaLevelAServiceInterface;
@Service
@Slf4j
@@ -51,12 +53,16 @@ public class EndpointConfiguration {
private Map groupDisableReasons = new ConcurrentHashMap<>();
private Map> endpointAlternatives = new ConcurrentHashMap<>();
private final boolean runningProOrHigher;
+ private final boolean pdfUaAvailable;
public EndpointConfiguration(
ApplicationProperties applicationProperties,
- @Qualifier("runningProOrHigher") boolean runningProOrHigher) {
+ @Qualifier("runningProOrHigher") boolean runningProOrHigher,
+ @Autowired(required = false) PdfaLevelAServiceInterface pdfaLevelAService) {
this.applicationProperties = applicationProperties;
this.runningProOrHigher = runningProOrHigher;
+ // The PDF/UA tagger ships in the proprietary module, and so do its endpoints.
+ this.pdfUaAvailable = pdfaLevelAService != null;
init();
processEnvironmentConfigs();
}
@@ -356,6 +362,7 @@ public class EndpointConfiguration {
addEndpointToGroup("Convert", "pdf-to-img");
addEndpointToGroup("Convert", "img-to-pdf");
addEndpointToGroup("Convert", "pdf-to-pdfa");
+ addEndpointToGroup("Convert", "pdf-to-ua");
addEndpointToGroup("Convert", "file-to-pdf");
addEndpointToGroup("Convert", "pdf-to-word");
addEndpointToGroup("Convert", "pdf-to-presentation");
@@ -395,6 +402,7 @@ public class EndpointConfiguration {
// Backend-only endpoints (not in frontend tool registry endpoints)
addEndpointToGroup("Security", "redact");
addEndpointToGroup("Security", "verify-pdf");
+ addEndpointToGroup("Security", "accessibility-report");
addEndpointToGroup("Security", "sign");
// Adding endpoints to "Other" group
@@ -529,6 +537,8 @@ public class EndpointConfiguration {
addEndpointToGroup("Java", "json-to-pdf");
addEndpointToGroup("Java", "pdf-to-video");
addEndpointToGroup("Java", "verify-pdf");
+ addEndpointToGroup("Java", "pdf-to-ua");
+ addEndpointToGroup("Java", "accessibility-report");
addEndpointToGroup("Java", "flatten");
addEndpointToGroup("Java", "unlock-pdf-forms");
addEndpointToGroup("Java", "validate-signature");
@@ -600,6 +610,8 @@ public class EndpointConfiguration {
// veraPDF dependent endpoints
addEndpointToGroup("veraPDF", "verify-pdf");
+ addEndpointToGroup("veraPDF", "pdf-to-ua");
+ addEndpointToGroup("veraPDF", "accessibility-report");
// Pdftohtml dependent endpoints
addEndpointToGroup("Pdftohtml", "pdf-to-html");
@@ -630,6 +642,11 @@ public class EndpointConfiguration {
disableGroup("enterprise");
}
+ if (!pdfUaAvailable) {
+ disableEndpoint("pdf-to-ua");
+ disableEndpoint("accessibility-report");
+ }
+
if (!applicationProperties.getSystem().isEnableUrlToPDF()) {
disableEndpoint("url-to-pdf");
}
diff --git a/app/common/src/main/java/stirling/software/common/service/PdfaLevelAServiceInterface.java b/app/common/src/main/java/stirling/software/common/service/PdfaLevelAServiceInterface.java
new file mode 100644
index 0000000000..2ee55719b2
--- /dev/null
+++ b/app/common/src/main/java/stirling/software/common/service/PdfaLevelAServiceInterface.java
@@ -0,0 +1,22 @@
+package stirling.software.common.service;
+
+import java.util.List;
+
+/**
+ * Raises a converted PDF/A file from conformance level B to level A, which needs the tagging the
+ * PDF/UA tagger does. Implemented only in the proprietary module; core builds convert at level B.
+ */
+public interface PdfaLevelAServiceInterface {
+
+ /**
+ * @param levelA true only when the file was tagged and validated, so the claim is never a guess
+ */
+ record Result(byte[] pdfBytes, boolean levelA, List warnings) {}
+
+ /**
+ * @param part PDF/A part, 1 to 3; part 1 keeps its PDF 1.4 version
+ * @param alsoDeclareUa additionally claim PDF/UA, but only if it validates
+ */
+ Result upgradeToLevelA(
+ byte[] pdfBytes, int part, String language, String title, boolean alsoDeclareUa);
+}
diff --git a/app/common/src/test/java/stirling/software/SPDF/config/EndpointConfigurationGapTest.java b/app/common/src/test/java/stirling/software/SPDF/config/EndpointConfigurationGapTest.java
index 6275b49343..afc6fa7020 100644
--- a/app/common/src/test/java/stirling/software/SPDF/config/EndpointConfigurationGapTest.java
+++ b/app/common/src/test/java/stirling/software/SPDF/config/EndpointConfigurationGapTest.java
@@ -17,6 +17,7 @@ import org.junit.jupiter.api.Test;
import stirling.software.SPDF.config.EndpointConfiguration.DisableReason;
import stirling.software.SPDF.config.EndpointConfiguration.EndpointAvailability;
import stirling.software.common.model.ApplicationProperties;
+import stirling.software.common.service.PdfaLevelAServiceInterface;
/**
* Unit tests for {@link EndpointConfiguration}. The class wires up its endpoint/group registry in
@@ -32,7 +33,14 @@ class EndpointConfigurationGapTest {
* Construct an EndpointConfiguration with the given pro flag and current applicationProperties.
*/
private EndpointConfiguration build(boolean runningProOrHigher) {
- return new EndpointConfiguration(applicationProperties, runningProOrHigher);
+ return build(runningProOrHigher, null);
+ }
+
+ /** The PDF/UA service is only present in proprietary builds, so it is injected separately. */
+ private EndpointConfiguration build(
+ boolean runningProOrHigher, PdfaLevelAServiceInterface pdfaLevelAService) {
+ return new EndpointConfiguration(
+ applicationProperties, runningProOrHigher, pdfaLevelAService);
}
/** Default config: not pro, no removals, url-to-pdf disabled (default System flag is false). */
@@ -177,6 +185,28 @@ class EndpointConfigurationGapTest {
}
}
+ @Nested
+ @DisplayName("PDF/UA availability")
+ class PdfUaTests {
+
+ @Test
+ @DisplayName("the PDF/UA endpoints are off when the proprietary tagger is absent")
+ void disabledWithoutTagger() {
+ EndpointConfiguration config = build(false, null);
+ assertFalse(config.isEndpointEnabled("pdf-to-ua"));
+ assertFalse(config.isEndpointEnabled("accessibility-report"));
+ }
+
+ @Test
+ @DisplayName("they are on once the tagger is on the classpath")
+ void enabledWithTagger() {
+ EndpointConfiguration config =
+ build(false, (pdfBytes, part, language, title, alsoDeclareUa) -> null);
+ assertTrue(config.isEndpointEnabled("pdf-to-ua"));
+ assertTrue(config.isEndpointEnabled("accessibility-report"));
+ }
+ }
+
@Nested
@DisplayName("group enable / disable")
class GroupTests {
diff --git a/app/core/src/main/java/stirling/software/SPDF/controller/api/converters/ConvertPDFToPDFA.java b/app/core/src/main/java/stirling/software/SPDF/controller/api/converters/ConvertPDFToPDFA.java
index 49bf4e4895..0354817315 100644
--- a/app/core/src/main/java/stirling/software/SPDF/controller/api/converters/ConvertPDFToPDFA.java
+++ b/app/core/src/main/java/stirling/software/SPDF/controller/api/converters/ConvertPDFToPDFA.java
@@ -11,6 +11,7 @@ import java.time.Instant;
import java.time.ZoneId;
import java.time.ZonedDateTime;
import java.util.*;
+import java.util.Locale;
import java.util.regex.Pattern;
import java.util.stream.Collectors;
import java.util.stream.Stream;
@@ -71,6 +72,7 @@ import org.apache.xmpbox.schema.PDFAIdentificationSchema;
import org.apache.xmpbox.schema.XMPBasicSchema;
import org.apache.xmpbox.xml.DomXmpParser;
import org.apache.xmpbox.xml.XmpSerializer;
+import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.core.io.Resource;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
@@ -83,7 +85,6 @@ import io.github.pixee.security.Filenames;
import io.swagger.v3.oas.annotations.Operation;
import lombok.Getter;
-import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import stirling.software.SPDF.model.api.converters.PdfToPdfARequest;
@@ -93,6 +94,7 @@ import stirling.software.common.configuration.RuntimePathConfig;
import stirling.software.common.enumeration.ResourceWeight;
import stirling.software.common.model.tool.ToolFormat;
import stirling.software.common.model.tool.ToolIO;
+import stirling.software.common.service.PdfaLevelAServiceInterface;
import stirling.software.common.util.ExceptionUtils;
import stirling.software.common.util.ProcessExecutor;
import stirling.software.common.util.ProcessExecutor.ProcessExecutorResult;
@@ -102,14 +104,26 @@ import stirling.software.common.util.WebResponseUtils;
@ConvertApi
@Slf4j
-@RequiredArgsConstructor
public class ConvertPDFToPDFA {
private static final Pattern NON_PRINTABLE_ASCII = Pattern.compile("[^\\x20-\\x7E]");
private final RuntimePathConfig runtimePathConfig;
private final stirling.software.SPDF.service.VeraPDFService veraPDFService;
+ // Level A needs the proprietary tagger; core builds convert at level B instead.
+ private final PdfaLevelAServiceInterface pdfaLevelAService;
private final TempFileManager tempFileManager;
+ public ConvertPDFToPDFA(
+ RuntimePathConfig runtimePathConfig,
+ stirling.software.SPDF.service.VeraPDFService veraPDFService,
+ @Autowired(required = false) PdfaLevelAServiceInterface pdfaLevelAService,
+ TempFileManager tempFileManager) {
+ this.runtimePathConfig = runtimePathConfig;
+ this.veraPDFService = veraPDFService;
+ this.pdfaLevelAService = pdfaLevelAService;
+ this.tempFileManager = tempFileManager;
+ }
+
private static final String ICC_RESOURCE_PATH = "/icc/sRGB2014.icc";
private static final int PDFA_COMPATIBILITY_POLICY = 1;
@@ -604,7 +618,10 @@ public class ConvertPDFToPDFA {
return handlePdfXConversion(inputFile, outputFormat);
} else {
return handlePdfAConversion(
- inputFile, outputFormat, request.getStrict() != null && request.getStrict());
+ inputFile,
+ outputFormat,
+ request.getStrict() != null && request.getStrict(),
+ request.getPdfUa() != null && request.getPdfUa());
}
}
@@ -1815,8 +1832,64 @@ public class ConvertPDFToPDFA {
return Files.readAllBytes(outputPdf);
}
+ /** Tags a converted PDF/A for level A; must run after Ghostscript, which discards tags. */
+ private PdfaLevelAServiceInterface.Result applyLevelA(
+ byte[] converted,
+ Path original,
+ PdfaProfile profile,
+ String baseFileName,
+ boolean declarePdfUa) {
+ if (!profile.requiresTagging()) {
+ return new PdfaLevelAServiceInterface.Result(converted, true, List.of());
+ }
+ if (pdfaLevelAService == null) {
+ return new PdfaLevelAServiceInterface.Result(
+ converted,
+ false,
+ List.of(
+ "Level A tagging is not available in this build, so the file was left"
+ + " at conformance level B."));
+ }
+ // Prefer the document's own title/language; hardcoding "en" mislabelled German reports.
+ // Read the original, not the converted bytes: Ghostscript discards /Lang, so probing its
+ // output always yields null and every document would be relabelled with the default.
+ String language = null;
+ String title = null;
+ try (PDDocument probe = Loader.loadPDF(original.toFile())) {
+ language = probe.getDocumentCatalog().getLanguage();
+ title = probe.getDocumentInformation().getTitle();
+ } catch (IOException e) {
+ log.debug("Could not read original title/language: {}", e.getMessage());
+ }
+ if (language == null || language.isBlank()) {
+ try (PDDocument probe = Loader.loadPDF(converted)) {
+ language = probe.getDocumentCatalog().getLanguage();
+ if (title == null || title.isBlank()) {
+ title = probe.getDocumentInformation().getTitle();
+ }
+ } catch (IOException e) {
+ log.debug("Could not read converted title/language: {}", e.getMessage());
+ }
+ }
+ PdfaLevelAServiceInterface.Result result =
+ pdfaLevelAService.upgradeToLevelA(
+ converted,
+ profile.getPart(),
+ language,
+ title != null && !title.isBlank() ? title : baseFileName,
+ declarePdfUa);
+ result.warnings().forEach(warning -> log.info("PDF/A level A: {}", warning));
+ if (!result.levelA()) {
+ log.warn(
+ "{} requested but the document could not be tagged; returning level B",
+ profile.getDisplayName());
+ }
+ return result;
+ }
+
private ResponseEntity handlePdfAConversion(
- MultipartFile inputFile, String outputFormat, boolean strict) throws Exception {
+ MultipartFile inputFile, String outputFormat, boolean strict, boolean declarePdfUa)
+ throws Exception {
PdfaProfile profile = PdfaProfile.fromRequest(outputFormat);
// Get the original filename without extension
@@ -1841,12 +1914,15 @@ public class ConvertPDFToPDFA {
log.info("Using Ghostscript for PDF/A conversion to {}", profile.getDisplayName());
try {
converted = convertWithGhostscript(inputPath, workingDir, profile);
- String outputFilename = baseFileName + profile.outputSuffix();
+ var levelA =
+ applyLevelA(converted, inputPath, profile, baseFileName, declarePdfUa);
+ converted = levelA.pdfBytes();
+ String outputFilename = baseFileName + profile.outputSuffix(levelA.levelA());
validateAndWarnPdfA(converted, profile, "Ghostscript");
if (strict) {
- verifyStrictCompliance(converted);
+ verifyStrictCompliance(converted, profile, levelA.levelA());
}
TempFile tempOut = tempFileManager.createManagedTempFile(".pdf");
@@ -1867,13 +1943,15 @@ public class ConvertPDFToPDFA {
}
converted = convertWithPdfBoxMethod(inputPath, profile);
- String outputFilename = baseFileName + profile.outputSuffix();
+ var levelA = applyLevelA(converted, inputPath, profile, baseFileName, declarePdfUa);
+ converted = levelA.pdfBytes();
+ String outputFilename = baseFileName + profile.outputSuffix(levelA.levelA());
// Validate with PDFBox preflight and warn if issues found
validateAndWarnPdfA(converted, profile, "PDFBox/LibreOffice");
if (strict) {
- verifyStrictCompliance(converted);
+ verifyStrictCompliance(converted, profile, levelA.levelA());
}
TempFile tempOut = tempFileManager.createManagedTempFile(".pdf");
@@ -1889,11 +1967,56 @@ public class ConvertPDFToPDFA {
}
}
- private void verifyStrictCompliance(byte[] pdfBytes) throws IOException {
+ /** True for a PDF/UA or WCAG result, which says nothing about archival conformance. */
+ private static boolean isAccessibilityProfile(
+ stirling.software.SPDF.model.api.security.PDFVerificationResult result) {
+ String profile = result.getValidationProfile();
+ if (profile == null) {
+ return false;
+ }
+ String normalised = profile.toLowerCase(Locale.ROOT);
+ return normalised.contains("ua") || normalised.contains("wcag");
+ }
+
+ /**
+ * True when a result speaks for the requested profile. Only archival results count, and a level
+ * B pass must never satisfy a level A request.
+ */
+ private static boolean answersRequest(
+ PdfaProfile profile,
+ stirling.software.SPDF.model.api.security.PDFVerificationResult result) {
+ if (isAccessibilityProfile(result)) {
+ return false;
+ }
+ String standard = result.getStandard();
+ if (standard == null || standard.length() < 2) {
+ return false;
+ }
+ if (standard.charAt(0) != Character.forDigit(profile.getPart(), 10)) {
+ return false;
+ }
+ return !profile.requiresTagging() || Character.toLowerCase(standard.charAt(1)) == 'a';
+ }
+
+ private void verifyStrictCompliance(byte[] pdfBytes, PdfaProfile profile, boolean levelAReached)
+ throws IOException {
+ // Tagging is the only route to level A, so an untagged file cannot answer a strict request.
+ if (!levelAReached) {
+ throw new ResponseStatusException(
+ HttpStatus.BAD_REQUEST,
+ "Strict PDF/A mode enabled: the document could not be tagged, so "
+ + profile.getDisplayName()
+ + " was not reached. It is valid at level B.");
+ }
try (InputStream is = new ByteArrayInputStream(pdfBytes)) {
List results =
veraPDFService.validatePDF(is);
- boolean isCompliant = results.stream().anyMatch(result -> result.isCompliant());
+ boolean isCompliant =
+ results.stream()
+ .filter(result -> answersRequest(profile, result))
+ .anyMatch(
+ stirling.software.SPDF.model.api.security.PDFVerificationResult
+ ::isCompliant);
if (!isCompliant) {
String details =
results.stream()
@@ -1901,7 +2024,9 @@ public class ConvertPDFToPDFA {
.collect(Collectors.joining("; "));
throw new ResponseStatusException(
HttpStatus.BAD_REQUEST,
- "Strict PDF/A mode enabled: Conversion is not perfectly compliant. Details: "
+ "Strict PDF/A mode enabled: the output is not perfectly compliant with "
+ + profile.getDisplayName()
+ + ". Details: "
+ details);
}
} catch (Exception e) {
@@ -2466,11 +2591,16 @@ public class ConvertPDFToPDFA {
@Getter
private enum PdfaProfile {
- PDF_A_1B(1, "PDF/A-1b", "_PDFA-1b.pdf", "1.4", Format.PDF_A1B, "pdfa-1"),
- PDF_A_2B(2, "PDF/A-2b", "_PDFA-2b.pdf", "1.7", null, "pdfa", "pdfa-2", "pdfa-2b"),
- PDF_A_3B(3, "PDF/A-3b", "_PDFA-3b.pdf", "1.7", null, "pdfa-3", "pdfa-3b");
+ PDF_A_1B(1, "B", "PDF/A-1b", "_PDFA-1b.pdf", "1.4", Format.PDF_A1B, "pdfa-1"),
+ PDF_A_2B(2, "B", "PDF/A-2b", "_PDFA-2b.pdf", "1.7", null, "pdfa", "pdfa-2", "pdfa-2b"),
+ PDF_A_3B(3, "B", "PDF/A-3b", "_PDFA-3b.pdf", "1.7", null, "pdfa-3", "pdfa-3b"),
+ // Level A = level B plus tagging, declared language and Unicode text; tagged post-convert.
+ PDF_A_1A(1, "A", "PDF/A-1a", "_PDFA-1a.pdf", "1.4", Format.PDF_A1B, "pdfa-1a"),
+ PDF_A_2A(2, "A", "PDF/A-2a", "_PDFA-2a.pdf", "1.7", null, "pdfa-2a"),
+ PDF_A_3A(3, "A", "PDF/A-3a", "_PDFA-3a.pdf", "1.7", null, "pdfa-3a");
private final int part;
+ private final String conformanceLevel;
private final String displayName;
private final String suffix;
private final String compatibilityLevel;
@@ -2479,12 +2609,14 @@ public class ConvertPDFToPDFA {
PdfaProfile(
int part,
+ String conformanceLevel,
String displayName,
String suffix,
String compatibilityLevel,
Format preflightFormat,
String... requestTokens) {
this.part = part;
+ this.conformanceLevel = conformanceLevel;
this.displayName = displayName;
this.suffix = suffix;
this.compatibilityLevel = compatibilityLevel;
@@ -2495,6 +2627,10 @@ public class ConvertPDFToPDFA {
.toList();
}
+ boolean requiresTagging() {
+ return "A".equals(conformanceLevel);
+ }
+
static PdfaProfile fromRequest(String requestToken) {
if (requestToken == null) {
return PDF_A_2B;
@@ -2508,8 +2644,11 @@ public class ConvertPDFToPDFA {
return match.orElse(PDF_A_2B);
}
- String outputSuffix() {
- return suffix;
+ /**
+ * Names the file at the level actually reached; a level A name over level B content lies.
+ */
+ String outputSuffix(boolean levelAReached) {
+ return levelAReached ? suffix : "_PDFA-" + part + "b.pdf";
}
Optional preflightFormat() {
diff --git a/app/core/src/main/java/stirling/software/SPDF/model/api/converters/PdfToPdfARequest.java b/app/core/src/main/java/stirling/software/SPDF/model/api/converters/PdfToPdfARequest.java
index bb0520a4ba..921663912b 100644
--- a/app/core/src/main/java/stirling/software/SPDF/model/api/converters/PdfToPdfARequest.java
+++ b/app/core/src/main/java/stirling/software/SPDF/model/api/converters/PdfToPdfARequest.java
@@ -14,9 +14,19 @@ public class PdfToPdfARequest extends PDFFile {
@Schema(
description = "The output format type (PDF/A or PDF/X)",
requiredMode = Schema.RequiredMode.REQUIRED,
- allowableValues = {"pdfa", "pdfa-1", "pdfa-2", "pdfa-2b", "pdfa-3", "pdfa-3b", "pdfx"})
+ allowableValues = {
+ "pdfa", "pdfa-1", "pdfa-2", "pdfa-2b", "pdfa-3", "pdfa-3b", "pdfa-1a", "pdfa-2a",
+ "pdfa-3a", "pdfx"
+ })
private String outputFormat;
+ @Schema(
+ description =
+ "Also declare PDF/UA accessibility alongside PDF/A. Only applies to the level A"
+ + " formats, and the claim is written only if it validates.",
+ defaultValue = "false")
+ private Boolean pdfUa;
+
@Schema(
description =
"If true, the conversion will fail if the output is not perfectly compliant")
diff --git a/app/core/src/main/java/stirling/software/SPDF/service/VeraPDFService.java b/app/core/src/main/java/stirling/software/SPDF/service/VeraPDFService.java
index bb3c84534c..6361157b21 100644
--- a/app/core/src/main/java/stirling/software/SPDF/service/VeraPDFService.java
+++ b/app/core/src/main/java/stirling/software/SPDF/service/VeraPDFService.java
@@ -285,6 +285,8 @@ public class VeraPDFService {
}
}
+ // Never force PDF/UA here - it flags every ordinary document as non-compliant and doubles
+ // verify cost; /accessibility-report checks PDF/UA on demand.
if (!hasPdfaDeclaration) {
results.add(createNoPdfaDeclarationResult());
}
diff --git a/app/core/src/test/java/stirling/software/SPDF/controller/api/converters/ConvertPDFToPDFAGapTest.java b/app/core/src/test/java/stirling/software/SPDF/controller/api/converters/ConvertPDFToPDFAGapTest.java
index b64776665b..ea693ddd27 100644
--- a/app/core/src/test/java/stirling/software/SPDF/controller/api/converters/ConvertPDFToPDFAGapTest.java
+++ b/app/core/src/test/java/stirling/software/SPDF/controller/api/converters/ConvertPDFToPDFAGapTest.java
@@ -46,6 +46,7 @@ import stirling.software.SPDF.model.api.converters.PdfToPdfARequest;
import stirling.software.SPDF.model.api.security.PDFVerificationResult;
import stirling.software.SPDF.service.VeraPDFService;
import stirling.software.common.configuration.RuntimePathConfig;
+import stirling.software.common.service.PdfaLevelAServiceInterface;
import stirling.software.common.util.TempFileManager;
/**
@@ -62,10 +63,12 @@ class ConvertPDFToPDFAGapTest {
@Mock private RuntimePathConfig runtimePathConfig;
@Mock private VeraPDFService veraPDFService;
+ @Mock private PdfaLevelAServiceInterface pdfaLevelAService;
@Mock private TempFileManager tempFileManager;
private ConvertPDFToPDFA newController() {
- return new ConvertPDFToPDFA(runtimePathConfig, veraPDFService, tempFileManager);
+ return new ConvertPDFToPDFA(
+ runtimePathConfig, veraPDFService, pdfaLevelAService, tempFileManager);
}
// ---- reflection helpers ----------------------------------------------------------------
@@ -161,9 +164,21 @@ class ConvertPDFToPDFAGapTest {
}
private String suffixOf(Object profile) throws Exception {
- Method m = profile.getClass().getDeclaredMethod("outputSuffix");
+ return suffixOf(profile, true);
+ }
+
+ private String suffixOf(Object profile, boolean levelAReached) throws Exception {
+ Method m = profile.getClass().getDeclaredMethod("outputSuffix", boolean.class);
m.setAccessible(true);
- return (String) m.invoke(profile);
+ return (String) m.invoke(profile, levelAReached);
+ }
+
+ @Test
+ @DisplayName("a level A profile falls back to the level B name when tagging failed")
+ void levelANotReachedIsNamedLevelB() throws Exception {
+ assertThat(suffixOf(resolveProfile("pdfa-1a"), false)).isEqualTo("_PDFA-1b.pdf");
+ assertThat(suffixOf(resolveProfile("pdfa-2a"), false)).isEqualTo("_PDFA-2b.pdf");
+ assertThat(suffixOf(resolveProfile("pdfa-3a"), true)).isEqualTo("_PDFA-3a.pdf");
}
@Test
@@ -717,6 +732,30 @@ class ConvertPDFToPDFAGapTest {
@DisplayName("verifyStrictCompliance (VeraPDFService mocked)")
class StrictCompliance {
+ private Object profile(String token) throws Exception {
+ Class> enumClass = null;
+ for (Class> inner : ConvertPDFToPDFA.class.getDeclaredClasses()) {
+ if (inner.getSimpleName().equals("PdfaProfile")) {
+ enumClass = inner;
+ }
+ }
+ Method m = enumClass.getDeclaredMethod("fromRequest", String.class);
+ m.setAccessible(true);
+ return m.invoke(null, token);
+ }
+
+ private Throwable verify(String token, boolean levelAReached) throws Exception {
+ ConvertPDFToPDFA controller = newController();
+ return catchThrowable(
+ () ->
+ invokeInstance(
+ controller,
+ "verifyStrictCompliance",
+ (Object) "dummy".getBytes(),
+ profile(token),
+ levelAReached));
+ }
+
@Test
@DisplayName("compliant result passes without throwing")
void compliantPasses() throws Exception {
@@ -726,14 +765,7 @@ class ConvertPDFToPDFAGapTest {
ok.setComplianceSummary("PDF/A-1b compliant");
when(veraPDFService.validatePDF(any())).thenReturn(List.of(ok));
- ConvertPDFToPDFA controller = newController();
- assertThatCode(
- () ->
- invokeInstance(
- controller,
- "verifyStrictCompliance",
- (Object) "dummy".getBytes()))
- .doesNotThrowAnyException();
+ assertThat(verify("pdfa-1", true)).isNull();
}
@Test
@@ -745,34 +777,70 @@ class ConvertPDFToPDFAGapTest {
bad.setComplianceSummary("PDF/A-1b with errors");
when(veraPDFService.validatePDF(any())).thenReturn(List.of(bad));
- ConvertPDFToPDFA controller = newController();
- ResponseStatusException ex =
- (ResponseStatusException)
- catchThrowable(
- () ->
- invokeInstance(
- controller,
- "verifyStrictCompliance",
- (Object) "dummy".getBytes()));
+ ResponseStatusException ex = (ResponseStatusException) verify("pdfa-1", true);
assertThat(ex).isNotNull();
assertThat(ex.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST);
assertThat(ex.getReason()).contains("PDF/A-1b with errors");
}
+ @Test
+ @DisplayName("a level B pass does not satisfy a level A request")
+ void levelBDoesNotSatisfyLevelA() throws Exception {
+ PDFVerificationResult ok = new PDFVerificationResult();
+ ok.setCompliant(true);
+ ok.setStandard("1b");
+ ok.setComplianceSummary("PDF/A-1b compliant");
+ when(veraPDFService.validatePDF(any())).thenReturn(List.of(ok));
+
+ ResponseStatusException ex = (ResponseStatusException) verify("pdfa-1a", true);
+ assertThat(ex).isNotNull();
+ assertThat(ex.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST);
+ assertThat(ex.getReason()).contains("PDF/A-1a");
+ }
+
+ @Test
+ @DisplayName("a level A result satisfies a level A request")
+ void levelASatisfiesLevelA() throws Exception {
+ PDFVerificationResult ok = new PDFVerificationResult();
+ ok.setCompliant(true);
+ ok.setStandard("2a");
+ ok.setComplianceSummary("PDF/A-2a compliant");
+ when(veraPDFService.validatePDF(any())).thenReturn(List.of(ok));
+
+ assertThat(verify("pdfa-2a", true)).isNull();
+ }
+
+ @Test
+ @DisplayName("untagged output fails a level A request before validation runs")
+ void untaggedLevelARequestFails() throws Exception {
+ ResponseStatusException ex = (ResponseStatusException) verify("pdfa-2a", false);
+ assertThat(ex).isNotNull();
+ assertThat(ex.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST);
+ assertThat(ex.getReason()).contains("could not be tagged");
+ verifyNoInteractions(veraPDFService);
+ }
+
+ @Test
+ @DisplayName("a compliant PDF/UA result never satisfies a strict PDF/A request")
+ void accessibilityResultIsIgnored() throws Exception {
+ PDFVerificationResult ua = new PDFVerificationResult();
+ ua.setCompliant(true);
+ ua.setStandard("ua1");
+ ua.setValidationProfile("ua1");
+ ua.setComplianceSummary("PDF/UA-1 compliant");
+ when(veraPDFService.validatePDF(any())).thenReturn(List.of(ua));
+
+ ResponseStatusException ex = (ResponseStatusException) verify("pdfa-2b", true);
+ assertThat(ex).isNotNull();
+ assertThat(ex.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST);
+ }
+
@Test
@DisplayName("empty result list is treated as non-compliant -> 400")
void emptyResultsTreatedNonCompliant() throws Exception {
when(veraPDFService.validatePDF(any())).thenReturn(Collections.emptyList());
- ConvertPDFToPDFA controller = newController();
- ResponseStatusException ex =
- (ResponseStatusException)
- catchThrowable(
- () ->
- invokeInstance(
- controller,
- "verifyStrictCompliance",
- (Object) "dummy".getBytes()));
+ ResponseStatusException ex = (ResponseStatusException) verify("pdfa-1", true);
assertThat(ex).isNotNull();
assertThat(ex.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST);
}
@@ -782,15 +850,7 @@ class ConvertPDFToPDFAGapTest {
void serviceErrorWrappedAs500() throws Exception {
when(veraPDFService.validatePDF(any())).thenThrow(new IOException("boom"));
- ConvertPDFToPDFA controller = newController();
- ResponseStatusException ex =
- (ResponseStatusException)
- catchThrowable(
- () ->
- invokeInstance(
- controller,
- "verifyStrictCompliance",
- (Object) "dummy".getBytes()));
+ ResponseStatusException ex = (ResponseStatusException) verify("pdfa-1", true);
assertThat(ex).isNotNull();
assertThat(ex.getStatusCode()).isEqualTo(HttpStatus.INTERNAL_SERVER_ERROR);
}
diff --git a/app/core/src/test/java/stirling/software/SPDF/controller/api/converters/ConvertPDFToPDFAMoreTest.java b/app/core/src/test/java/stirling/software/SPDF/controller/api/converters/ConvertPDFToPDFAMoreTest.java
index e9d9b9ce1d..d56a283464 100644
--- a/app/core/src/test/java/stirling/software/SPDF/controller/api/converters/ConvertPDFToPDFAMoreTest.java
+++ b/app/core/src/test/java/stirling/software/SPDF/controller/api/converters/ConvertPDFToPDFAMoreTest.java
@@ -42,6 +42,7 @@ import org.springframework.mock.web.MockMultipartFile;
import stirling.software.SPDF.model.api.converters.PdfToPdfARequest;
import stirling.software.SPDF.service.VeraPDFService;
import stirling.software.common.configuration.RuntimePathConfig;
+import stirling.software.common.service.PdfaLevelAServiceInterface;
import stirling.software.common.util.ProcessExecutor;
import stirling.software.common.util.ProcessExecutor.ProcessExecutorResult;
import stirling.software.common.util.TempFile;
@@ -63,10 +64,12 @@ class ConvertPDFToPDFAMoreTest {
@Mock private RuntimePathConfig runtimePathConfig;
@Mock private VeraPDFService veraPDFService;
+ @Mock private PdfaLevelAServiceInterface pdfaLevelAService;
@Mock private TempFileManager tempFileManager;
private ConvertPDFToPDFA newController() {
- return new ConvertPDFToPDFA(runtimePathConfig, veraPDFService, tempFileManager);
+ return new ConvertPDFToPDFA(
+ runtimePathConfig, veraPDFService, pdfaLevelAService, tempFileManager);
}
private static ResponseEntity streamingOk(byte[] bytes) {
diff --git a/app/core/src/test/java/stirling/software/SPDF/service/VeraPDFServicePdfaFixtureTest.java b/app/core/src/test/java/stirling/software/SPDF/service/VeraPDFServicePdfaFixtureTest.java
index 38043780d9..e071ece3fb 100644
--- a/app/core/src/test/java/stirling/software/SPDF/service/VeraPDFServicePdfaFixtureTest.java
+++ b/app/core/src/test/java/stirling/software/SPDF/service/VeraPDFServicePdfaFixtureTest.java
@@ -90,7 +90,9 @@ class VeraPDFServicePdfaFixtureTest {
() -> service.validatePDF(new ByteArrayInputStream(pdfBytes)),
"Empty veraPDF flavour list must not surface as IndexOutOfBoundsException");
- assertEquals(1, results.size());
+ // One result: PDF/UA is checked by the dedicated accessibility-report endpoint, not here.
+ assertEquals(1, results.size(), () -> "Expected a single PDF/A result, got: " + results);
+
PDFVerificationResult result = results.get(0);
assertEquals("not-pdfa", result.getStandard());
assertFalse(result.isDeclaredPdfa());
@@ -161,13 +163,22 @@ class VeraPDFServicePdfaFixtureTest {
}
}
+ /** The PDF/A result; every document is also checked against PDF/UA, so filter that one out. */
private PDFVerificationResult onlyResult(byte[] pdfBytes) throws Exception {
List results =
service.validatePDF(new ByteArrayInputStream(pdfBytes));
assertNotNull(results);
- assertEquals(1, results.size(), () -> "Expected a single result, got: " + results);
- return results.get(0);
+ List pdfaResults =
+ results.stream().filter(r -> !isUaResult(r)).toList();
+ assertEquals(
+ 1, pdfaResults.size(), () -> "Expected a single PDF/A result, got: " + results);
+ return pdfaResults.get(0);
+ }
+
+ private static boolean isUaResult(PDFVerificationResult result) {
+ String profile = result.getValidationProfile();
+ return profile != null && profile.toLowerCase().contains("ua");
}
private static String messages(PDFVerificationResult result) {
diff --git a/app/proprietary/build.gradle b/app/proprietary/build.gradle
index c20241dbb5..b884cb18be 100644
--- a/app/proprietary/build.gradle
+++ b/app/proprietary/build.gradle
@@ -37,6 +37,15 @@ dependencies {
// https://mvnrepository.com/artifact/com.bucket4j/bucket4j_jdk17
implementation "org.bouncycastle:bcprov-jdk18on:$bouncycastleVersion"
+ // PDF/UA tagging and its validation oracle.
+ implementation 'org.verapdf:validation-model:1.30.2'
+ // CVE-2025-66453: Explicit rhino 1.7.15 to override verapdf's 1.7.13
+ implementation "org.mozilla:rhino:${rhinoVersion}"
+ // veraPDF still uses javax.xml.bind, not the new jakarta namespace
+ implementation 'javax.xml.bind:jaxb-api:2.3.1'
+ runtimeOnly 'com.sun.xml.bind:jaxb-impl:2.3.9'
+ runtimeOnly 'com.sun.xml.bind:jaxb-core:4.0.9'
+
implementation "com.google.code.gson:gson:${gsonVersion}"
// jinjava/jjwt transitively request older Jackson 2 versions; declare the current
diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/controller/api/converters/ConvertPdfToPdfUa.java b/app/proprietary/src/main/java/stirling/software/proprietary/controller/api/converters/ConvertPdfToPdfUa.java
new file mode 100644
index 0000000000..7f5241388f
--- /dev/null
+++ b/app/proprietary/src/main/java/stirling/software/proprietary/controller/api/converters/ConvertPdfToPdfUa.java
@@ -0,0 +1,176 @@
+package stirling.software.proprietary.controller.api.converters;
+
+import java.io.IOException;
+import java.nio.file.Files;
+import java.util.LinkedHashMap;
+import java.util.Map;
+import java.util.regex.Pattern;
+
+import org.springframework.core.io.Resource;
+import org.springframework.http.MediaType;
+import org.springframework.http.ResponseEntity;
+import org.springframework.web.bind.annotation.ModelAttribute;
+import org.springframework.web.multipart.MultipartFile;
+
+import io.github.pixee.security.Filenames;
+import io.swagger.v3.oas.annotations.Operation;
+
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+
+import stirling.software.common.annotations.AutoJobPostMapping;
+import stirling.software.common.annotations.api.ConvertApi;
+import stirling.software.common.enumeration.ResourceWeight;
+import stirling.software.common.model.tool.ToolFormat;
+import stirling.software.common.model.tool.ToolIO;
+import stirling.software.common.util.ExceptionUtils;
+import stirling.software.common.util.TempFile;
+import stirling.software.common.util.TempFileManager;
+import stirling.software.common.util.WebResponseUtils;
+import stirling.software.proprietary.model.api.converters.PdfToPdfUaRequest;
+import stirling.software.proprietary.model.api.ua.PdfUaConversionOutcome;
+import stirling.software.proprietary.pdf.ua.PdfUaProfile;
+import stirling.software.proprietary.pdf.ua.TaggingOptions;
+import stirling.software.proprietary.service.ua.PdfUaConversionService;
+
+/** Converts a PDF to PDF/UA; response headers say whether the result actually conforms. */
+@ConvertApi
+@Slf4j
+@RequiredArgsConstructor
+public class ConvertPdfToPdfUa {
+
+ private static final String HEADER_DECLARED = "X-Stirling-UA-Declared";
+ private static final String HEADER_FAILURES = "X-Stirling-UA-Failures";
+ private static final String HEADER_ALT_NEEDED = "X-Stirling-UA-Figures-Needing-Alt";
+ private static final String HEADER_WARNINGS = "X-Stirling-UA-Warnings";
+
+ /** Any line ending, so descriptions pasted from any platform parse the same. */
+ private static final Pattern NEWLINE = Pattern.compile("\\R");
+
+ private final PdfUaConversionService conversionService;
+ private final TempFileManager tempFileManager;
+
+ @AutoJobPostMapping(
+ consumes = MediaType.MULTIPART_FORM_DATA_VALUE,
+ value = "/pdf/ua",
+ resourceWeight = ResourceWeight.LARGE_WEIGHT)
+ @ToolIO(produces = ToolFormat.PDF)
+ @Operation(
+ summary = "Convert a PDF to PDF/UA-1 or PDF/UA-2",
+ description =
+ "Tags the document, marks decorative content as artifacts, embeds fonts and"
+ + " applies the document-level requirements of PDF/UA, then validates"
+ + " the result. A conformance declaration is written only if validation"
+ + " passes, so the returned file never claims more than it delivers.")
+ public ResponseEntity pdfToPdfUa(@ModelAttribute PdfToPdfUaRequest request)
+ throws IOException {
+
+ MultipartFile input = request.getFileInput();
+ if (input == null || input.isEmpty()) {
+ throw ExceptionUtils.createPdfFileRequiredException();
+ }
+
+ String originalName = Filenames.toSimpleFileName(input.getOriginalFilename());
+ String stem = stripExtension(originalName == null ? "document" : originalName);
+ PdfUaProfile profile = PdfUaProfile.fromRequest(request.getProfile());
+
+ TaggingOptions options =
+ TaggingOptions.builder()
+ .profile(profile)
+ .title(request.getTitle())
+ .fallbackTitle(stem)
+ // Only used when the document declares no language of its own.
+ .language(
+ request.getLanguage() == null || request.getLanguage().isBlank()
+ ? "en-GB"
+ : request.getLanguage())
+ .overrideLanguage(
+ request.getOverrideLanguage() != null
+ && request.getOverrideLanguage())
+ .existingTags(existingTags(request.getExistingTags()))
+ .figurePolicy(figurePolicy(request.getFigurePolicy()))
+ .embedFonts(request.getEmbedFonts() == null || request.getEmbedFonts())
+ .altTextByFigure(parseAltText(request.getAltText()))
+ .build();
+
+ PdfUaConversionOutcome outcome = conversionService.convert(input.getBytes(), options);
+
+ log.info(
+ "Converted '{}' to {}: declared={}, {} remaining failure(s)",
+ originalName,
+ profile.displayName(),
+ outcome.declared(),
+ outcome.validation().totalFailures());
+
+ outcome.warnings().forEach(warning -> log.info("PDF/UA warning: {}", warning));
+
+ // Streamed from a temp file so a large conversion does not hold a second heap copy.
+ String suffix = outcome.declared() ? "_pdfua" + profile.part() : "_tagged";
+ TempFile tempOut = tempFileManager.createManagedTempFile(".pdf");
+ try {
+ Files.write(tempOut.getPath(), outcome.pdfBytes());
+ } catch (IOException e) {
+ tempOut.close();
+ throw e;
+ }
+ ResponseEntity response =
+ WebResponseUtils.pdfFileToWebResponse(tempOut, stem + suffix + ".pdf");
+
+ return ResponseEntity.status(response.getStatusCode())
+ .headers(response.getHeaders())
+ .header(HEADER_DECLARED, String.valueOf(outcome.declared()))
+ .header(HEADER_FAILURES, String.valueOf(outcome.validation().totalFailures()))
+ .header(
+ HEADER_ALT_NEEDED,
+ String.valueOf(outcome.tagging().figuresNeedingAltText()))
+ // Count only: warning text is multi-line prose, which HTTP headers mangle.
+ .header(HEADER_WARNINGS, String.valueOf(outcome.warnings().size()))
+ .body(response.getBody());
+ }
+
+ /**
+ * Parses newline-separated {@code key=description} pairs, keyed as the report hands them out.
+ * Only the first "=" splits, since a description may contain one.
+ */
+ public static Map parseAltText(String raw) {
+ if (raw == null || raw.isBlank()) {
+ return Map.of();
+ }
+ Map parsed = new LinkedHashMap<>();
+ for (String line : NEWLINE.split(raw)) {
+ int split = line.indexOf('=');
+ if (split <= 0) {
+ continue;
+ }
+ String key = line.substring(0, split).strip();
+ String description = line.substring(split + 1).strip();
+ if (!key.isEmpty() && !description.isEmpty()) {
+ parsed.put(key, description);
+ }
+ }
+ return parsed;
+ }
+
+ private static TaggingOptions.ExistingTags existingTags(String value) {
+ if (value == null) {
+ return TaggingOptions.ExistingTags.AUTO;
+ }
+ return switch (value.trim().toLowerCase()) {
+ case "keep" -> TaggingOptions.ExistingTags.KEEP;
+ case "rebuild" -> TaggingOptions.ExistingTags.REBUILD;
+ default -> TaggingOptions.ExistingTags.AUTO;
+ };
+ }
+
+ private static TaggingOptions.FigurePolicy figurePolicy(String value) {
+ if (value != null && value.trim().equalsIgnoreCase("mark-decorative")) {
+ return TaggingOptions.FigurePolicy.MARK_DECORATIVE;
+ }
+ return TaggingOptions.FigurePolicy.REQUIRE_ALT;
+ }
+
+ private static String stripExtension(String filename) {
+ int dot = filename.lastIndexOf('.');
+ return dot > 0 ? filename.substring(0, dot) : filename;
+ }
+}
diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/controller/api/security/AccessibilityReportController.java b/app/proprietary/src/main/java/stirling/software/proprietary/controller/api/security/AccessibilityReportController.java
new file mode 100644
index 0000000000..043734dad8
--- /dev/null
+++ b/app/proprietary/src/main/java/stirling/software/proprietary/controller/api/security/AccessibilityReportController.java
@@ -0,0 +1,67 @@
+package stirling.software.proprietary.controller.api.security;
+
+import java.io.IOException;
+
+import org.springframework.http.MediaType;
+import org.springframework.http.ResponseEntity;
+import org.springframework.web.bind.annotation.ModelAttribute;
+import org.springframework.web.multipart.MultipartFile;
+
+import io.swagger.v3.oas.annotations.Operation;
+
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+
+import stirling.software.common.annotations.AutoJobPostMapping;
+import stirling.software.common.annotations.api.SecurityApi;
+import stirling.software.common.enumeration.ResourceWeight;
+import stirling.software.common.model.tool.ToolFormat;
+import stirling.software.common.model.tool.ToolIO;
+import stirling.software.common.util.ExceptionUtils;
+import stirling.software.proprietary.model.api.ua.AccessibilityReport;
+import stirling.software.proprietary.model.api.ua.AccessibilityReportRequest;
+import stirling.software.proprietary.pdf.ua.PdfUaProfile;
+import stirling.software.proprietary.service.ua.AccessibilityAuditService;
+
+/** Reports how accessible a document is, without modifying it. */
+@SecurityApi
+@RequiredArgsConstructor
+@Slf4j
+public class AccessibilityReportController {
+
+ private final AccessibilityAuditService auditService;
+
+ @ToolIO(produces = ToolFormat.JSON)
+ @Operation(
+ summary = "Report a document's accessibility standing",
+ description =
+ "Validates the document against PDF/UA and reports what fails, which failures"
+ + " can be fixed automatically, and which checks still need a person."
+ + " Does not modify the file.")
+ // Costs a full veraPDF pass plus the converter's own layout analysis over every page.
+ @AutoJobPostMapping(
+ value = "/accessibility-report",
+ consumes = MediaType.MULTIPART_FORM_DATA_VALUE,
+ resourceWeight = ResourceWeight.LARGE_WEIGHT)
+ public ResponseEntity report(
+ @ModelAttribute AccessibilityReportRequest request) {
+
+ MultipartFile file = request.getFileInput();
+ if (file == null || file.isEmpty()) {
+ throw ExceptionUtils.createPdfFileRequiredException();
+ }
+ PdfUaProfile profile = PdfUaProfile.fromRequest(request.getProfile());
+ try {
+ AccessibilityReport report = auditService.audit(file.getBytes(), profile);
+ log.info(
+ "Accessibility report for '{}': tagged={}, {} issue(s)",
+ file.getOriginalFilename(),
+ report.isTagged(),
+ report.getIssues().size());
+ return ResponseEntity.ok(report);
+ } catch (IOException e) {
+ throw ExceptionUtils.createRuntimeException(
+ "error.ioException", "Could not read the PDF: {0}", e, e.getMessage());
+ }
+ }
+}
diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/model/api/converters/PdfToPdfUaRequest.java b/app/proprietary/src/main/java/stirling/software/proprietary/model/api/converters/PdfToPdfUaRequest.java
new file mode 100644
index 0000000000..8981398001
--- /dev/null
+++ b/app/proprietary/src/main/java/stirling/software/proprietary/model/api/converters/PdfToPdfUaRequest.java
@@ -0,0 +1,73 @@
+package stirling.software.proprietary.model.api.converters;
+
+import io.swagger.v3.oas.annotations.media.Schema;
+
+import lombok.Data;
+import lombok.EqualsAndHashCode;
+
+import stirling.software.common.model.api.PDFFile;
+
+@Data
+@EqualsAndHashCode(callSuper = true)
+public class PdfToPdfUaRequest extends PDFFile {
+
+ @Schema(
+ description = "PDF/UA conformance level to target",
+ defaultValue = "ua1",
+ allowableValues = {"ua1", "ua2"})
+ private String profile;
+
+ @Schema(
+ description =
+ "Document title, required by PDF/UA. Falls back to the first heading, then the"
+ + " filename.")
+ private String title;
+
+ @Schema(
+ description =
+ "Document language as a BCP-47 tag, for example en-GB. Applied only when the"
+ + " document does not already declare one, unless overrideLanguage is"
+ + " set.",
+ defaultValue = "en-GB")
+ private String language;
+
+ @Schema(
+ description =
+ "Replace the language the document already declares. Off by default, so a"
+ + " document is never relabelled into a language it is not written in.",
+ defaultValue = "false")
+ private Boolean overrideLanguage;
+
+ @Schema(
+ description =
+ "What to do with an existing structure tree: keep it, rebuild it, or decide"
+ + " automatically",
+ defaultValue = "auto",
+ allowableValues = {"auto", "keep", "rebuild"})
+ private String existingTags;
+
+ @Schema(
+ description =
+ "How to treat images with no description. require-alt leaves them undescribed so"
+ + " the report asks for input; mark-decorative treats every image as"
+ + " decoration.",
+ defaultValue = "require-alt",
+ allowableValues = {"require-alt", "mark-decorative"})
+ private String figurePolicy;
+
+ @Schema(
+ description =
+ "Embed fonts the document references but does not carry. Required for"
+ + " conformance and needs Ghostscript.",
+ defaultValue = "true")
+ private Boolean embedFonts;
+
+ @Schema(
+ description =
+ "Alternative descriptions for figures, as key=text pairs separated by newlines."
+ + " Keys come from the accessibility-report endpoint's"
+ + " figuresNeedingDescription list, for example \"0:12=Bar chart of"
+ + " quarterly revenue\". Descriptions are never invented, so without"
+ + " these an illustrated document cannot claim conformance.")
+ private String altText;
+}
diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/model/api/ua/AccessibilityIssue.java b/app/proprietary/src/main/java/stirling/software/proprietary/model/api/ua/AccessibilityIssue.java
new file mode 100644
index 0000000000..0c2a86ad99
--- /dev/null
+++ b/app/proprietary/src/main/java/stirling/software/proprietary/model/api/ua/AccessibilityIssue.java
@@ -0,0 +1,38 @@
+package stirling.software.proprietary.model.api.ua;
+
+import io.swagger.v3.oas.annotations.media.Schema;
+
+import lombok.Data;
+
+/** One accessibility problem, grouped across all of its occurrences. */
+@Data
+@Schema(description = "A single accessibility issue found in a document")
+public class AccessibilityIssue {
+
+ @Schema(description = "ISO 14289 clause, e.g. 7.3")
+ private String clause;
+
+ @Schema(description = "Test number within the clause")
+ private String testNumber;
+
+ @Schema(description = "Plain-English description of the problem")
+ private String message;
+
+ @Schema(description = "The validator's own wording, for support and debugging")
+ private String technicalMessage;
+
+ @Schema(description = "error or warning")
+ private String severity = "error";
+
+ @Schema(description = "Standard the check came from, e.g. PDF/UA-1")
+ private String specification;
+
+ @Schema(description = "Where the problem was found, when the validator reports it")
+ private String location;
+
+ @Schema(description = "How many times this issue occurs")
+ private int occurrences;
+
+ @Schema(description = "True when the converter can fix this without human input")
+ private boolean autoFixable;
+}
diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/model/api/ua/AccessibilityReport.java b/app/proprietary/src/main/java/stirling/software/proprietary/model/api/ua/AccessibilityReport.java
new file mode 100644
index 0000000000..bc810635d1
--- /dev/null
+++ b/app/proprietary/src/main/java/stirling/software/proprietary/model/api/ua/AccessibilityReport.java
@@ -0,0 +1,63 @@
+package stirling.software.proprietary.model.api.ua;
+
+import java.util.List;
+
+import io.swagger.v3.oas.annotations.media.Schema;
+
+import lombok.Data;
+
+/**
+ * A document's accessibility standing. The machine/human split is load-bearing: veraPDF covers only
+ * about half of the Matterhorn Protocol, so a clean automated pass is not "accessible".
+ */
+@Data
+@Schema(description = "Accessibility standing of a document")
+public class AccessibilityReport {
+
+ @Schema(description = "Profile the document was checked against, e.g. PDF/UA-1")
+ private String profile;
+
+ @Schema(description = "Whether the document has a structure tree at all")
+ private boolean tagged;
+
+ @Schema(description = "Whether the document declares PDF/UA conformance in its metadata")
+ private boolean declaresConformance;
+
+ @Schema(description = "Whether every automated check passed")
+ private boolean passesAutomatedChecks;
+
+ @Schema(description = "Automated checks that failed, grouped by rule")
+ private List issues = List.of();
+
+ @Schema(description = "Things a person still has to verify; automation cannot decide these")
+ private List humanChecks = List.of();
+
+ @Schema(description = "How many of the failing checks the converter can fix on its own")
+ private int automaticallyFixable;
+
+ @Schema(description = "How many need information from the user, such as alternative text")
+ private int needsInput;
+
+ @Schema(
+ description =
+ "Figures that need an alternative description. Each carries the key to pass"
+ + " back in the conversion request's altTextByFigure map, so a caller"
+ + " can enumerate what is missing and then supply it.")
+ private List figuresNeedingDescription = List.of();
+
+ @Schema(description = "Document-level facts that drive most failures")
+ private Summary summary = new Summary();
+
+ @Data
+ @Schema(description = "Quick document-level facts")
+ public static class Summary {
+ private int pages;
+ private boolean hasTitle;
+ private boolean displaysDocTitle;
+ private boolean hasLanguage;
+ private boolean allFontsEmbedded;
+ private int unembeddedFonts;
+ private int figures;
+ private boolean encrypted;
+ }
+}
diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/model/api/ua/AccessibilityReportRequest.java b/app/proprietary/src/main/java/stirling/software/proprietary/model/api/ua/AccessibilityReportRequest.java
new file mode 100644
index 0000000000..178d2637b2
--- /dev/null
+++ b/app/proprietary/src/main/java/stirling/software/proprietary/model/api/ua/AccessibilityReportRequest.java
@@ -0,0 +1,19 @@
+package stirling.software.proprietary.model.api.ua;
+
+import io.swagger.v3.oas.annotations.media.Schema;
+
+import lombok.Data;
+import lombok.EqualsAndHashCode;
+
+import stirling.software.common.model.api.PDFFile;
+
+@Data
+@EqualsAndHashCode(callSuper = true)
+public class AccessibilityReportRequest extends PDFFile {
+
+ @Schema(
+ description = "Profile to check against",
+ defaultValue = "ua1",
+ allowableValues = {"ua1", "ua2"})
+ private String profile;
+}
diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/model/api/ua/FigureDescriptor.java b/app/proprietary/src/main/java/stirling/software/proprietary/model/api/ua/FigureDescriptor.java
new file mode 100644
index 0000000000..1c960d87e7
--- /dev/null
+++ b/app/proprietary/src/main/java/stirling/software/proprietary/model/api/ua/FigureDescriptor.java
@@ -0,0 +1,18 @@
+package stirling.software.proprietary.model.api.ua;
+
+import io.swagger.v3.oas.annotations.media.Schema;
+
+/**
+ * One figure needing an alternative description, which is never invented. key is the
+ * altTextByFigure key "pageIndex:ordinal"; page is 1-based; kind is "figure" or "formula".
+ */
+@Schema(description = "A figure that needs an alternative description")
+public record FigureDescriptor(
+ String key,
+ int page,
+ String kind,
+ float x,
+ float y,
+ float width,
+ float height,
+ String existingAlt) {}
diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/model/api/ua/PdfUaConversionOutcome.java b/app/proprietary/src/main/java/stirling/software/proprietary/model/api/ua/PdfUaConversionOutcome.java
new file mode 100644
index 0000000000..dcef97c916
--- /dev/null
+++ b/app/proprietary/src/main/java/stirling/software/proprietary/model/api/ua/PdfUaConversionOutcome.java
@@ -0,0 +1,26 @@
+package stirling.software.proprietary.model.api.ua;
+
+import java.util.List;
+
+import io.swagger.v3.oas.annotations.media.Schema;
+
+/**
+ * Result of a PDF/UA conversion.
+ *
+ * @param declared whether a {@code pdfuaid} conformance claim was written into {@code pdfBytes}
+ */
+@Schema(description = "Result of converting a document to PDF/UA")
+public record PdfUaConversionOutcome(
+ byte[] pdfBytes,
+ boolean declared,
+ UaValidationResult validation,
+ TaggingSummary tagging,
+ List warnings) {
+
+ @Schema(description = "What the tagging pass produced")
+ public record TaggingSummary(
+ boolean rebuiltStructure,
+ int taggedElements,
+ int artifacts,
+ int figuresNeedingAltText) {}
+}
diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/model/api/ua/UaValidationResult.java b/app/proprietary/src/main/java/stirling/software/proprietary/model/api/ua/UaValidationResult.java
new file mode 100644
index 0000000000..5e494c35be
--- /dev/null
+++ b/app/proprietary/src/main/java/stirling/software/proprietary/model/api/ua/UaValidationResult.java
@@ -0,0 +1,18 @@
+package stirling.software.proprietary.model.api.ua;
+
+import java.util.List;
+
+import io.swagger.v3.oas.annotations.media.Schema;
+
+/**
+ * Outcome of validating against one PDF/UA profile. compliant means every automated check passed,
+ * which is not the same as usable by assistive technology; totalFailures is ungrouped.
+ */
+@Schema(description = "Result of validating a document against a PDF/UA profile")
+public record UaValidationResult(
+ String profile, boolean compliant, List issues, int totalFailures) {
+
+ public boolean hasIssues() {
+ return !issues.isEmpty();
+ }
+}
diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/pdf/ua/ArtifactType.java b/app/proprietary/src/main/java/stirling/software/proprietary/pdf/ua/ArtifactType.java
new file mode 100644
index 0000000000..78df1fbc27
--- /dev/null
+++ b/app/proprietary/src/main/java/stirling/software/proprietary/pdf/ua/ArtifactType.java
@@ -0,0 +1,23 @@
+package stirling.software.proprietary.pdf.ua;
+
+/** Artifact subtypes (ISO 32000-1 14.8.2.2). Artifacts are excluded from the structure tree. */
+public enum ArtifactType {
+ /** Running heads, folios, page numbers. Required by PDF/UA-1 clause 7.8. */
+ PAGINATION("Pagination"),
+ /** Rules, boxes, and other layout ornamentation. */
+ LAYOUT("Layout"),
+ /** Cut marks and colour bars. */
+ PAGE("Page"),
+ /** Background graphics with no informational content. */
+ BACKGROUND("Background");
+
+ private final String subtype;
+
+ ArtifactType(String subtype) {
+ this.subtype = subtype;
+ }
+
+ public String subtype() {
+ return subtype;
+ }
+}
diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/pdf/ua/BBox.java b/app/proprietary/src/main/java/stirling/software/proprietary/pdf/ua/BBox.java
new file mode 100644
index 0000000000..f2fbf97438
--- /dev/null
+++ b/app/proprietary/src/main/java/stirling/software/proprietary/pdf/ua/BBox.java
@@ -0,0 +1,48 @@
+package stirling.software.proprietary.pdf.ua;
+
+/** An axis-aligned rectangle in PDF user space, with y increasing upwards. */
+public record BBox(float x0, float y0, float x1, float y1) {
+
+ public static final BBox EMPTY = new BBox(0, 0, 0, 0);
+
+ public static BBox of(float x, float y, float width, float height) {
+ return new BBox(x, y, x + width, y + height);
+ }
+
+ public float width() {
+ return x1 - x0;
+ }
+
+ public float height() {
+ return y1 - y0;
+ }
+
+ public float centreX() {
+ return (x0 + x1) / 2f;
+ }
+
+ public BBox union(BBox other) {
+ if (other == null || other.isEmpty()) {
+ return this;
+ }
+ if (isEmpty()) {
+ return other;
+ }
+ return new BBox(
+ Math.min(x0, other.x0),
+ Math.min(y0, other.y0),
+ Math.max(x1, other.x1),
+ Math.max(y1, other.y1));
+ }
+
+ public boolean isEmpty() {
+ return x1 <= x0 || y1 <= y0;
+ }
+
+ /** Horizontal overlap with another box as a fraction of the narrower box's width. */
+ public float horizontalOverlap(BBox other) {
+ float overlap = Math.min(x1, other.x1) - Math.max(x0, other.x0);
+ float narrower = Math.min(width(), other.width());
+ return narrower <= 0 ? 0 : Math.max(0, overlap) / narrower;
+ }
+}
diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/pdf/ua/DocumentStructure.java b/app/proprietary/src/main/java/stirling/software/proprietary/pdf/ua/DocumentStructure.java
new file mode 100644
index 0000000000..5922895453
--- /dev/null
+++ b/app/proprietary/src/main/java/stirling/software/proprietary/pdf/ua/DocumentStructure.java
@@ -0,0 +1,84 @@
+package stirling.software.proprietary.pdf.ua;
+
+import java.util.ArrayList;
+import java.util.List;
+import java.util.function.Consumer;
+
+import lombok.Getter;
+import lombok.Setter;
+
+/** The derived logical structure of a document, ready for serialisation into a structure tree. */
+@Getter
+@Setter
+public class DocumentStructure {
+
+ /** Top-level blocks in document reading order. */
+ private final List blocks = new ArrayList<>();
+
+ /** Warnings raised during analysis, surfaced in the conversion report. */
+ private final List warnings = new ArrayList<>();
+
+ private String title;
+ private String language;
+
+ /** True when real text was wrapped as artifacts, which blocks any conformance claim. */
+ private boolean textSuppressed;
+
+ /** Body text size used as the baseline for heading detection, in points. */
+ private float bodyFontSize;
+
+ public void add(StructBlock block) {
+ blocks.add(block);
+ }
+
+ public void warn(String message) {
+ if (!warnings.contains(message)) {
+ warnings.add(message);
+ }
+ }
+
+ public void visit(Consumer visitor) {
+ blocks.forEach(block -> block.visit(visitor));
+ }
+
+ public int count(StructType type) {
+ int[] total = {0};
+ visit(
+ block -> {
+ if (block.getType() == type) {
+ total[0]++;
+ }
+ });
+ return total[0];
+ }
+
+ public int artifactCount() {
+ int[] total = {0};
+ visit(
+ block -> {
+ if (block.isArtifact()) {
+ total[0]++;
+ }
+ });
+ return total[0];
+ }
+
+ /** Figures with no alternative description, the most common PDF/UA failure. */
+ public List figuresWithoutAlt() {
+ List missing = new ArrayList<>();
+ visit(
+ block -> {
+ if ((block.getType() == StructType.FIGURE
+ || block.getType() == StructType.FORMULA)
+ && (block.getAlt() == null || block.getAlt().isBlank())
+ && (block.getActualText() == null || block.getActualText().isBlank())) {
+ missing.add(block);
+ }
+ });
+ return missing;
+ }
+
+ public boolean isEmpty() {
+ return blocks.isEmpty();
+ }
+}
diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/pdf/ua/LayoutAnalyzer.java b/app/proprietary/src/main/java/stirling/software/proprietary/pdf/ua/LayoutAnalyzer.java
new file mode 100644
index 0000000000..11c510d6ea
--- /dev/null
+++ b/app/proprietary/src/main/java/stirling/software/proprietary/pdf/ua/LayoutAnalyzer.java
@@ -0,0 +1,831 @@
+package stirling.software.proprietary.pdf.ua;
+
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.Comparator;
+import java.util.HashMap;
+import java.util.HashSet;
+import java.util.IdentityHashMap;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+import java.util.regex.Pattern;
+import java.util.stream.Collectors;
+
+import lombok.extern.slf4j.Slf4j;
+
+/**
+ * Derives a logical structure from extracted lines and graphics, reusing {@code HeadingDetector}'s
+ * heuristics. Degrades to paragraphs rather than guessing, since a wrong tag misleads readers.
+ */
+@Slf4j
+public class LayoutAnalyzer {
+
+ private static final Pattern BULLET = Pattern.compile("^[•‣◦⁃∙·▪●■o\\-\\*\\+]\\s+.*");
+ private static final Pattern ORDERED =
+ Pattern.compile("^(\\d{1,3}|[a-zA-Z]|[ivxlcIVXLC]{1,5})[\\.\\)]\\s+.*");
+ private static final Pattern PAGE_NUMBER =
+ Pattern.compile(
+ "^(page\\s+)?\\d{1,4}(\\s*(of|/)\\s*\\d{1,4})?$", Pattern.CASE_INSENSITIVE);
+ private static final Pattern DIGITS = Pattern.compile("\\d+");
+
+ /** Fraction of page height treated as the running head / foot band. */
+ private static final float MARGIN_BAND = 0.10f;
+
+ /** A line must exceed the body size by this ratio before it can be a heading. */
+ private static final float HEADING_RATIO = 1.10f;
+
+ /** Sizes within this many points are treated as the same heading tier. */
+ private static final float TIER_TOLERANCE = 0.4f;
+
+ private static final int MAX_HEADING_WORDS = 12;
+
+ /** Word gap beyond this multiple of the font size separates table cells. */
+ private static final float CELL_GAP_RATIO = 1.2f;
+
+ /** Images smaller than this in either dimension are decoration, not content. */
+ private static final float MIN_FIGURE_SIZE = 12f;
+
+ /** A size used by more than this share of lines is body text, however large the median says. */
+ private static final float MAX_HEADING_LINE_SHARE = 0.2f;
+
+ /** Consecutive lines sharing a size are a text block; headings appear alone. */
+ private static final int MAX_HEADING_RUN = 3;
+
+ /** A vector thinner than this in either dimension is a rule or border, not a drawing. */
+ private static final float MIN_VECTOR_THICKNESS = 3f;
+
+ /** Vector clusters smaller than this are ornament; larger ones are probably a chart. */
+ private static final float MIN_VECTOR_FIGURE_SIZE = 40f;
+
+ /** A drawing is built from several strokes; one big rectangle is a panel, not a chart. */
+ private static final int MIN_VECTOR_FIGURE_OPS = 4;
+
+ /** More text than this inside the region means shading behind content, not a drawing. */
+ private static final int MAX_LINES_INSIDE_FIGURE = 2;
+
+ public DocumentStructure analyse(List pages) {
+ DocumentStructure structure = new DocumentStructure();
+ float bodySize = bodyFontSize(pages);
+ structure.setBodyFontSize(bodySize);
+ Map tiers = headingTiers(pages, bodySize);
+ Map> artifactLines = repeatedMarginLines(pages, bodySize);
+
+ for (PageContent page : pages) {
+ analysePage(
+ page,
+ structure,
+ bodySize,
+ tiers,
+ artifactLines.getOrDefault(page.pageIndex(), List.of()));
+ }
+
+ List suppressedPages =
+ pages.stream()
+ .filter(PageContent::linesDropped)
+ .map(PageContent::pageIndex)
+ .toList();
+ if (!suppressedPages.isEmpty()) {
+ structure.setTextSuppressed(true);
+ structure.warn(
+ "Text on page(s) "
+ + suppressedPages.stream()
+ .map(i -> String.valueOf(i + 1))
+ .collect(Collectors.joining(", "))
+ + " could not be tagged reliably and was marked as artifacts. The"
+ + " converter will not claim conformance while real text is hidden"
+ + " from assistive technology.");
+ }
+
+ normaliseHeadingLevels(structure);
+ structure.setTitle(deriveTitle(structure));
+ return structure;
+ }
+
+ // --- Document-wide statistics -----------------------------------------
+
+ /** Character-weighted median line size, which is far more stable than a plain median. */
+ static float bodyFontSize(List pages) {
+ Map weights = new HashMap<>();
+ for (PageContent page : pages) {
+ for (TextLineInfo line : page.lines()) {
+ if (line.dominantFontSize() > 0 && !line.isBlank()) {
+ weights.merge(line.dominantFontSize(), line.charCount(), Integer::sum);
+ }
+ }
+ }
+ if (weights.isEmpty()) {
+ return 0f;
+ }
+ int total = weights.values().stream().mapToInt(Integer::intValue).sum();
+ List> sorted =
+ weights.entrySet().stream().sorted(Map.Entry.comparingByKey()).toList();
+ int seen = 0;
+ for (Map.Entry entry : sorted) {
+ seen += entry.getValue();
+ if (seen >= total / 2) {
+ return entry.getKey();
+ }
+ }
+ return sorted.get(sorted.size() - 1).getKey();
+ }
+
+ /** Maps each distinct heading size to a 1-based level, largest size first. */
+ static Map headingTiers(List pages, float bodySize) {
+ if (bodySize <= 0) {
+ return Map.of();
+ }
+ // A size used by a large share of the lines is body text, whatever the median says.
+ Map lineCounts = new HashMap<>();
+ int totalLines = 0;
+ for (PageContent page : pages) {
+ for (TextLineInfo line : page.lines()) {
+ if (!line.isBlank()) {
+ lineCounts.merge(line.dominantFontSize(), 1, Integer::sum);
+ totalLines++;
+ }
+ }
+ }
+ int headingLineCeiling = Math.max(1, (int) (totalLines * MAX_HEADING_LINE_SHARE));
+
+ // Headings do not cluster; a run of same-size lines is a text block, not headings.
+ Map longestRun = new HashMap<>();
+ for (PageContent page : pages) {
+ Float runSize = null;
+ int runLength = 0;
+ for (TextLineInfo line : page.lines()) {
+ if (line.isBlank()) {
+ continue;
+ }
+ float size = line.dominantFontSize();
+ if (runSize != null && Float.compare(size, runSize) == 0) {
+ runLength++;
+ } else {
+ runSize = size;
+ runLength = 1;
+ }
+ int seen = longestRun.getOrDefault(size, 0);
+ if (runLength > seen) {
+ longestRun.put(size, runLength);
+ }
+ }
+ }
+
+ List sizes = new ArrayList<>();
+ for (PageContent page : pages) {
+ for (TextLineInfo line : page.lines()) {
+ if (isHeadingCandidate(line)
+ && line.dominantFontSize() > bodySize * HEADING_RATIO
+ && lineCounts.getOrDefault(line.dominantFontSize(), 0) <= headingLineCeiling
+ && longestRun.getOrDefault(line.dominantFontSize(), 0) < MAX_HEADING_RUN) {
+ sizes.add(line.dominantFontSize());
+ }
+ }
+ }
+ List distinct = sizes.stream().distinct().sorted(Comparator.reverseOrder()).toList();
+
+ Map tiers = new LinkedHashMap<>();
+ int level = 0;
+ Float previous = null;
+ for (Float size : distinct) {
+ if (previous == null || previous - size > TIER_TOLERANCE) {
+ level = Math.min(level + 1, 6);
+ previous = size;
+ }
+ tiers.put(size, level);
+ }
+ return tiers;
+ }
+
+ /**
+ * Claims a line's operators word run by word run; claiming the whole ordinal interval would
+ * swallow anything drawn between them, an image included.
+ */
+ private static void claimLine(StructBlock block, TextLineInfo line) {
+ // Sort by ordinal, not position: merging out-of-order runs silently drops them to
+ // /Artifact, hiding them from assistive technology while the file still validates.
+ List words =
+ line.words().stream()
+ .filter(w -> !w.isBlank())
+ .sorted(Comparator.comparingInt(WordInfo::startOrdinal))
+ .toList();
+ if (words.isEmpty()) {
+ block.addRange(line.startOrdinal(), line.endOrdinal());
+ return;
+ }
+ int start = words.get(0).startOrdinal();
+ int end = words.get(0).endOrdinal();
+ for (int i = 1; i < words.size(); i++) {
+ WordInfo word = words.get(i);
+ if (word.startOrdinal() <= end + 1) {
+ end = Math.max(end, word.endOrdinal());
+ } else {
+ block.addRange(start, end);
+ start = word.startOrdinal();
+ end = word.endOrdinal();
+ }
+ }
+ block.addRange(start, end);
+ }
+
+ static boolean isHeadingCandidate(TextLineInfo line) {
+ String text = line.text().strip();
+ if (text.isEmpty() || line.wordCount() > MAX_HEADING_WORDS) {
+ return false;
+ }
+ char last = text.charAt(text.length() - 1);
+ return last != '.' && last != '!' && last != '?';
+ }
+
+ /**
+ * Finds lines in the head/foot bands whose text repeats across pages. Digits are masked first
+ * so that "Page 4" and "Page 5" count as the same running foot.
+ */
+ static Map> repeatedMarginLines(List pages) {
+ return repeatedMarginLines(pages, bodyFontSize(pages));
+ }
+
+ static Map> repeatedMarginLines(
+ List pages, float bodySize) {
+ Map> result = new HashMap<>();
+ if (pages.isEmpty()) {
+ return result;
+ }
+ Map counts = new HashMap<>();
+ Map> candidates = new HashMap<>();
+
+ for (PageContent page : pages) {
+ float height = page.mediaBox().height();
+ if (height <= 0) {
+ continue;
+ }
+ float topEdge = page.mediaBox().y1() - height * MARGIN_BAND;
+ float bottomEdge = page.mediaBox().y0() + height * MARGIN_BAND;
+ List inBand = new ArrayList<>();
+ for (TextLineInfo line : page.lines()) {
+ if (line.bbox().y0() >= topEdge || line.bbox().y1() <= bottomEdge) {
+ inBand.add(line);
+ counts.merge(mask(line.text()), 1, Integer::sum);
+ }
+ }
+ candidates.put(page.pageIndex(), inBand);
+ }
+
+ int threshold = Math.max(2, pages.size() / 2);
+ for (Map.Entry> entry : candidates.entrySet()) {
+ List artifacts = new ArrayList<>();
+ for (TextLineInfo line : entry.getValue()) {
+ boolean repeats =
+ pages.size() >= 3 && counts.getOrDefault(mask(line.text()), 0) >= threshold;
+ boolean pageNumber = PAGE_NUMBER.matcher(line.text().strip()).matches();
+ // Masked digits merge "Section 1" and "Section 2"; size is the tie-break that stops
+ // a real heading being demoted, as running heads are never larger than body text.
+ boolean looksLikeChrome =
+ bodySize <= 0 || line.dominantFontSize() <= bodySize * 1.05f;
+ if (pageNumber || (repeats && looksLikeChrome)) {
+ artifacts.add(line);
+ }
+ }
+ result.put(entry.getKey(), artifacts);
+ }
+ return result;
+ }
+
+ private static String mask(String text) {
+ return DIGITS.matcher(text.strip().toLowerCase()).replaceAll("#").replaceAll("\\s+", " ");
+ }
+
+ // --- Per-page analysis -------------------------------------------------
+
+ private void analysePage(
+ PageContent page,
+ DocumentStructure structure,
+ float bodySize,
+ Map tiers,
+ List marginArtifacts) {
+
+ for (TextLineInfo line : marginArtifacts) {
+ StructBlock artifact = StructBlock.artifact(ArtifactType.PAGINATION, page.pageIndex());
+ claimLine(artifact, line);
+ artifact.setBbox(line.bbox());
+ artifact.setText(line.text());
+ structure.add(artifact);
+ }
+
+ // Identity set, not List.contains: TextLineInfo is a record whose equals walks its word
+ // list, so a linear scan per line is quadratic with a deep comparison inside it.
+ java.util.Set marginSet = Collections.newSetFromMap(new IdentityHashMap<>());
+ marginSet.addAll(marginArtifacts);
+ List body =
+ page.lines().stream()
+ .filter(line -> !line.isBlank() && !marginSet.contains(line))
+ .sorted(readingOrder(page))
+ .toList();
+
+ List blocks = new ArrayList<>();
+ int index = 0;
+ while (index < body.size()) {
+ TextLineInfo line = body.get(index);
+
+ int tableEnd = tableRunEnd(body, index);
+ if (tableEnd > index) {
+ StructBlock table = buildTable(body.subList(index, tableEnd + 1), page.pageIndex());
+ if (table != null) {
+ blocks.add(table);
+ index = tableEnd + 1;
+ continue;
+ }
+ }
+
+ int listEnd = listRunEnd(body, index);
+ if (listEnd > index) {
+ blocks.add(buildList(body.subList(index, listEnd + 1), page.pageIndex()));
+ index = listEnd + 1;
+ continue;
+ }
+
+ Integer level = headingLevel(line, tiers);
+ if (level != null) {
+ StructBlock heading = new StructBlock(StructType.heading(level), page.pageIndex());
+ claimLine(heading, line);
+ heading.setBbox(line.bbox());
+ heading.setText(line.text());
+ blocks.add(heading);
+ index++;
+ continue;
+ }
+
+ int paragraphEnd = paragraphRunEnd(body, index, tiers, bodySize);
+ blocks.add(buildParagraph(body.subList(index, paragraphEnd + 1), page.pageIndex()));
+ index = paragraphEnd + 1;
+ }
+
+ // Form XObject text is attributed to its Do, so a Figure too would double-claim it.
+ Set claimed = new HashSet<>();
+ for (StructBlock block : blocks) {
+ block.visit(
+ node ->
+ node.getRanges()
+ .forEach(
+ range -> {
+ for (int i = range.start(); i <= range.end(); i++) {
+ claimed.add(i);
+ }
+ }));
+ }
+ blocks.addAll(buildGraphics(page, structure, claimed));
+ blocks.forEach(structure::add);
+ }
+
+ /**
+ * Orders lines top-to-bottom, splitting into columns first when the page is clearly
+ * multi-column. Without this, a two-column page reads as interleaved half-sentences.
+ */
+ private Comparator readingOrder(PageContent page) {
+ Float gutter = detectGutter(page);
+ if (gutter == null) {
+ return Comparator.comparingDouble((TextLineInfo l) -> -l.bbox().y1())
+ .thenComparingDouble(l -> l.bbox().x0());
+ }
+ return Comparator.comparingInt((TextLineInfo l) -> l.bbox().centreX() < gutter ? 0 : 1)
+ .thenComparingDouble(l -> -l.bbox().y1())
+ .thenComparingDouble(l -> l.bbox().x0());
+ }
+
+ /**
+ * Returns the x of a vertical gutter when the page is two-column, else null. A gutter must sit
+ * near the middle, be crossed by almost no line, and have substantial text on both sides.
+ */
+ static Float detectGutter(PageContent page) {
+ List lines = page.lines().stream().filter(line -> !line.isBlank()).toList();
+ if (lines.size() < 8) {
+ return null;
+ }
+ float pageWidth = page.mediaBox().width();
+ if (pageWidth <= 0) {
+ return null;
+ }
+ float centre = page.mediaBox().x0() + pageWidth / 2f;
+ long crossing =
+ lines.stream()
+ .filter(
+ line ->
+ line.bbox().x0() < centre - 5
+ && line.bbox().x1() > centre + 5)
+ .count();
+ if (crossing > lines.size() * 0.1) {
+ return null;
+ }
+ long left = lines.stream().filter(line -> line.bbox().centreX() < centre).count();
+ long right = lines.size() - left;
+ boolean balanced = left > lines.size() * 0.25 && right > lines.size() * 0.25;
+ return balanced ? centre : null;
+ }
+
+ private static Integer headingLevel(TextLineInfo line, Map tiers) {
+ if (!isHeadingCandidate(line)) {
+ return null;
+ }
+ return tiers.get(line.dominantFontSize());
+ }
+
+ // --- Paragraphs --------------------------------------------------------
+
+ private static int paragraphRunEnd(
+ List lines, int start, Map tiers, float bodySize) {
+ int end = start;
+ for (int i = start + 1; i < lines.size(); i++) {
+ TextLineInfo previous = lines.get(i - 1);
+ TextLineInfo current = lines.get(i);
+ if (headingLevel(current, tiers) != null || startsListItem(current)) {
+ break;
+ }
+ float gap = previous.bbox().y0() - current.bbox().y1();
+ float leading = Math.max(bodySize, current.bbox().height());
+ boolean sameBlock = gap < leading * 0.8f && gap > -leading;
+ boolean sentenceEnded = endsSentence(previous.text());
+ if (!sameBlock || (sentenceEnded && gap > leading * 0.4f)) {
+ break;
+ }
+ end = i;
+ }
+ return end;
+ }
+
+ private static boolean endsSentence(String text) {
+ String stripped = text.strip();
+ if (stripped.isEmpty()) {
+ return false;
+ }
+ char last = stripped.charAt(stripped.length() - 1);
+ return last == '.' || last == '!' || last == '?';
+ }
+
+ private static StructBlock buildParagraph(List lines, int pageIndex) {
+ StructBlock paragraph = new StructBlock(StructType.P, pageIndex);
+ BBox box = BBox.EMPTY;
+ StringBuilder text = new StringBuilder();
+ for (TextLineInfo line : lines) {
+ claimLine(paragraph, line);
+ box = box.union(line.bbox());
+ if (text.length() > 0) {
+ text.append(' ');
+ }
+ text.append(line.text().strip());
+ }
+ paragraph.setBbox(box);
+ paragraph.setText(text.toString());
+ return paragraph;
+ }
+
+ // --- Lists -------------------------------------------------------------
+
+ static boolean startsListItem(TextLineInfo line) {
+ String text = line.text().strip();
+ return BULLET.matcher(text).matches() || ORDERED.matcher(text).matches();
+ }
+
+ private static int listRunEnd(List lines, int start) {
+ if (!startsListItem(lines.get(start))) {
+ return start;
+ }
+ float indent = lines.get(start).bbox().x0();
+ int end = start;
+ for (int i = start + 1; i < lines.size(); i++) {
+ TextLineInfo line = lines.get(i);
+ boolean isItem = startsListItem(line) && Math.abs(line.bbox().x0() - indent) < 6f;
+ boolean isContinuation = !startsListItem(line) && line.bbox().x0() > indent + 2f;
+ if (!isItem && !isContinuation) {
+ break;
+ }
+ end = i;
+ }
+ // A single marker is a stray character, not a list.
+ long items =
+ lines.subList(start, end + 1).stream()
+ .filter(LayoutAnalyzer::startsListItem)
+ .count();
+ return items >= 2 ? end : start;
+ }
+
+ private static StructBlock buildList(List lines, int pageIndex) {
+ StructBlock list = new StructBlock(StructType.L, pageIndex);
+ list.setListNumbering(listNumbering(lines.get(0)));
+ BBox box = BBox.EMPTY;
+ StructBlock currentBody = null;
+
+ for (TextLineInfo line : lines) {
+ box = box.union(line.bbox());
+ if (startsListItem(line) || currentBody == null) {
+ StructBlock item = new StructBlock(StructType.LI, pageIndex);
+ StructBlock body = new StructBlock(StructType.LBODY, pageIndex);
+ claimLine(body, line);
+ body.setBbox(line.bbox());
+ body.setText(line.text());
+ item.addChild(body);
+ item.setBbox(line.bbox());
+ list.addChild(item);
+ currentBody = body;
+ } else {
+ claimLine(currentBody, line);
+ currentBody.setBbox(currentBody.getBbox().union(line.bbox()));
+ currentBody.setText(currentBody.getText() + " " + line.text().strip());
+ }
+ }
+ list.setBbox(box);
+ return list;
+ }
+
+ private static String listNumbering(TextLineInfo first) {
+ String text = first.text().strip();
+ if (BULLET.matcher(text).matches()) {
+ return "Disc";
+ }
+ char c = text.charAt(0);
+ if (Character.isDigit(c)) {
+ return "Decimal";
+ }
+ if ("ivxlc".indexOf(Character.toLowerCase(c)) >= 0 && text.length() > 1) {
+ return Character.isUpperCase(c) ? "UpperRoman" : "LowerRoman";
+ }
+ return Character.isUpperCase(c) ? "UpperAlpha" : "LowerAlpha";
+ }
+
+ // --- Tables ------------------------------------------------------------
+
+ /** Splits a line into cells wherever the gap between words exceeds the cell threshold. */
+ static List> splitCells(TextLineInfo line) {
+ List words = line.words().stream().filter(w -> !w.isBlank()).toList();
+ List> cells = new ArrayList<>();
+ if (words.isEmpty()) {
+ return cells;
+ }
+ float threshold = Math.max(line.dominantFontSize(), 1f) * CELL_GAP_RATIO;
+ List current = new ArrayList<>();
+ current.add(words.get(0));
+ for (int i = 1; i < words.size(); i++) {
+ float gap = words.get(i).bbox().x0() - words.get(i - 1).bbox().x1();
+ if (gap > threshold) {
+ cells.add(List.copyOf(current));
+ current = new ArrayList<>();
+ }
+ current.add(words.get(i));
+ }
+ cells.add(List.copyOf(current));
+ return cells;
+ }
+
+ /**
+ * Index of the last line of a table run starting at {@code start}, or {@code start} if none.
+ */
+ private static int tableRunEnd(List lines, int start) {
+ int end = start;
+ for (int i = start; i < lines.size(); i++) {
+ if (splitCells(lines.get(i)).size() < 2) {
+ break;
+ }
+ end = i;
+ }
+ return end > start ? end : start;
+ }
+
+ /**
+ * Builds a Table when the run really looks tabular and each cell owns its own operators.
+ * Returns null when it does not, so the caller falls back to paragraphs.
+ */
+ private static StructBlock buildTable(List rows, int pageIndex) {
+ if (rows.size() < 2) {
+ return null;
+ }
+ List>> grid = new ArrayList<>();
+ for (TextLineInfo row : rows) {
+ if (!row.wordsAreSeparable()) {
+ log.debug("Table row shares operators between cells; falling back to paragraphs");
+ return null;
+ }
+ grid.add(splitCells(row));
+ }
+ int columns = grid.get(0).size();
+ long consistent = grid.stream().filter(row -> row.size() == columns).count();
+ if (columns < 2 || consistent < Math.max(2, grid.size() * 0.6)) {
+ return null;
+ }
+
+ boolean headerRow = looksLikeHeader(rows, grid);
+ StructBlock table = new StructBlock(StructType.TABLE, pageIndex);
+ BBox box = BBox.EMPTY;
+
+ for (int r = 0; r < grid.size(); r++) {
+ List> cells = grid.get(r);
+ if (cells.size() != columns) {
+ continue;
+ }
+ StructBlock tr = new StructBlock(StructType.TR, pageIndex);
+ boolean isHeader = headerRow && r == 0;
+ for (List cell : cells) {
+ StructBlock td =
+ new StructBlock(isHeader ? StructType.TH : StructType.TD, pageIndex);
+ if (isHeader) {
+ td.setScope("Column");
+ }
+ BBox cellBox = BBox.EMPTY;
+ StringBuilder text = new StringBuilder();
+ int from = cell.get(0).startOrdinal();
+ int to = cell.get(cell.size() - 1).endOrdinal();
+ for (WordInfo word : cell) {
+ cellBox = cellBox.union(word.bbox());
+ if (text.length() > 0) {
+ text.append(' ');
+ }
+ text.append(word.text());
+ }
+ td.addRange(from, to);
+ td.setBbox(cellBox);
+ td.setText(text.toString());
+ tr.addChild(td);
+ box = box.union(cellBox);
+ }
+ tr.setBbox(box);
+ table.addChild(tr);
+ }
+ table.setBbox(box);
+ if (table.getChildren().size() < 2) {
+ return null;
+ }
+ // Clause 7.5 needs equal cell counts per row; a ragged table fails validation outright.
+ long distinctWidths =
+ table.getChildren().stream()
+ .map(row -> row.getChildren().size())
+ .distinct()
+ .count();
+ if (distinctWidths != 1) {
+ log.debug("Discarding a table whose rows have different cell counts");
+ return null;
+ }
+ return table;
+ }
+
+ /** The first row is a header when it is bold, or when only later rows carry numbers. */
+ private static boolean looksLikeHeader(
+ List rows, List>> grid) {
+ if (rows.get(0).bold()) {
+ return true;
+ }
+ boolean firstHasDigits = DIGITS.matcher(rows.get(0).text()).find();
+ boolean laterHasDigits =
+ rows.subList(1, rows.size()).stream()
+ .anyMatch(row -> DIGITS.matcher(row.text()).find());
+ return !firstHasDigits && laterHasDigits;
+ }
+
+ // --- Graphics ----------------------------------------------------------
+
+ private List buildGraphics(
+ PageContent page, DocumentStructure structure, java.util.Set claimed) {
+ List blocks = new ArrayList<>();
+ boolean warnedForms = false;
+
+ // Vectors cluster: a chart is many strokes in one region, a rule is a single thin one.
+ java.util.Set vectorFigureOrdinals = vectorFigureOrdinals(page, claimed);
+
+ for (MarkableOp op : page.ops()) {
+ if (op.kind() == MarkableOp.Kind.TEXT || claimed.contains(op.ordinal())) {
+ continue;
+ }
+ BBox box = op.bbox();
+
+ if (op.kind() == MarkableOp.Kind.VECTOR) {
+ StructBlock block;
+ if (vectorFigureOrdinals.contains(op.ordinal())) {
+ block = new StructBlock(StructType.FIGURE, page.pageIndex());
+ } else {
+ block = StructBlock.artifact(ArtifactType.LAYOUT, page.pageIndex());
+ }
+ block.addRange(op.ordinal(), op.ordinal());
+ block.setBbox(box);
+ blocks.add(block);
+ continue;
+ }
+
+ boolean decorative = box.width() < MIN_FIGURE_SIZE || box.height() < MIN_FIGURE_SIZE;
+ if (decorative) {
+ StructBlock artifact = StructBlock.artifact(ArtifactType.LAYOUT, page.pageIndex());
+ artifact.addRange(op.ordinal(), op.ordinal());
+ artifact.setBbox(box);
+ blocks.add(artifact);
+ continue;
+ }
+
+ if (op.kind() == MarkableOp.Kind.FORM && !warnedForms) {
+ structure.warn(
+ "Content inside form XObjects was tagged as a single region because its"
+ + " text is not separately addressable; review those areas.");
+ warnedForms = true;
+ }
+
+ StructBlock figure = new StructBlock(StructType.FIGURE, page.pageIndex());
+ figure.addRange(op.ordinal(), op.ordinal());
+ figure.setBbox(box);
+ blocks.add(figure);
+ }
+ return blocks;
+ }
+
+ /**
+ * Finds vector operators belonging to a substantial drawing rather than page furniture; thin
+ * paths are rules and table borders, and a short run is ornament.
+ */
+ private static Set vectorFigureOrdinals(
+ PageContent page, java.util.Set claimed) {
+ // A chart's plot area is mostly empty, while shading sits behind the text it decorates.
+ Set result = new HashSet<>();
+ List run = new ArrayList<>();
+ BBox extent = BBox.EMPTY;
+
+ for (MarkableOp op : page.ops()) {
+ boolean substantial =
+ op.kind() == MarkableOp.Kind.VECTOR
+ && !claimed.contains(op.ordinal())
+ && !op.bbox().isEmpty()
+ && op.bbox().width() >= MIN_VECTOR_THICKNESS
+ && op.bbox().height() >= MIN_VECTOR_THICKNESS;
+ if (substantial) {
+ run.add(op);
+ extent = extent.isEmpty() ? op.bbox() : extent.union(op.bbox());
+ continue;
+ }
+ flushVectorRun(run, extent, page.lines(), result);
+ run = new ArrayList<>();
+ extent = BBox.EMPTY;
+ }
+ flushVectorRun(run, extent, page.lines(), result);
+ return result;
+ }
+
+ private static void flushVectorRun(
+ List run,
+ BBox extent,
+ List lines,
+ java.util.Set result) {
+ if (run.size() < MIN_VECTOR_FIGURE_OPS
+ || extent.width() < MIN_VECTOR_FIGURE_SIZE
+ || extent.height() < MIN_VECTOR_FIGURE_SIZE) {
+ return;
+ }
+ if (overlappingLines(extent, lines) > MAX_LINES_INSIDE_FIGURE) {
+ return;
+ }
+ run.forEach(op -> result.add(op.ordinal()));
+ }
+
+ /** How many text lines sit within the region a vector cluster covers. */
+ private static int overlappingLines(BBox extent, List lines) {
+ int count = 0;
+ for (TextLineInfo line : lines) {
+ BBox box = line.bbox();
+ boolean inside =
+ box.x0() >= extent.x0() - 2
+ && box.x1() <= extent.x1() + 2
+ && box.y0() >= extent.y0() - 2
+ && box.y1() <= extent.y1() + 2;
+ if (inside) {
+ count++;
+ }
+ }
+ return count;
+ }
+
+ // --- Post-processing ---------------------------------------------------
+
+ /**
+ * Rewrites heading levels so no level is skipped, which PDF/UA-1 clause 7.4 requires. A
+ * document that jumps H1 to H3 is remapped to H1, H2 while preserving relative depth.
+ */
+ static void normaliseHeadingLevels(DocumentStructure structure) {
+ List headings = new ArrayList<>();
+ structure.visit(
+ block -> {
+ if (block.getType().isHeading()) {
+ headings.add(block);
+ }
+ });
+ int previous = 0;
+ for (StructBlock heading : headings) {
+ int level = heading.getType().headingLevel();
+ int adjusted = level > previous + 1 ? previous + 1 : level;
+ heading.setType(StructType.heading(adjusted));
+ previous = adjusted;
+ }
+ }
+
+ /** Uses the first top-level heading as the title when the document has no metadata title. */
+ private static String deriveTitle(DocumentStructure structure) {
+ for (StructBlock block : structure.getBlocks()) {
+ if (block.getType().isHeading() && !block.getText().isBlank()) {
+ return block.getText().strip();
+ }
+ }
+ return null;
+ }
+}
diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/pdf/ua/MarkableOp.java b/app/proprietary/src/main/java/stirling/software/proprietary/pdf/ua/MarkableOp.java
new file mode 100644
index 0000000000..c48319260f
--- /dev/null
+++ b/app/proprietary/src/main/java/stirling/software/proprietary/pdf/ua/MarkableOp.java
@@ -0,0 +1,44 @@
+package stirling.software.proprietary.pdf.ua;
+
+/**
+ * One operator in a page content stream that may be wrapped in a marked-content sequence. The
+ * ordinal counts only markable operators, joining text extraction to token rewriting.
+ */
+public record MarkableOp(int ordinal, Kind kind, BBox bbox, String resourceName) {
+
+ public enum Kind {
+ /** Tj, TJ, ' or " */
+ TEXT,
+ /** Do referencing an image XObject */
+ IMAGE,
+ /** Do referencing a form XObject */
+ FORM,
+ /** BI ... ID ... EI */
+ INLINE_IMAGE,
+ /** A path-painting or shading operator: rules, borders, fills, logos */
+ VECTOR;
+
+ public boolean isGraphic() {
+ return this == IMAGE || this == INLINE_IMAGE;
+ }
+ }
+
+ /**
+ * Operator names counted as markable; both passes must agree on this set. Path painting is
+ * included because clause 7.1 needs visible rules and borders tagged or artifacted.
+ */
+ public static boolean isMarkableOperator(String name) {
+ return switch (name) {
+ case "Tj", "TJ", "'", "\"", "Do", "BI" -> true;
+ default -> isPathPainting(name);
+ };
+ }
+
+ /** Painting operators only: {@code n} ends a path without marking the page. */
+ public static boolean isPathPainting(String name) {
+ return switch (name) {
+ case "S", "s", "f", "F", "f*", "B", "B*", "b", "b*", "sh" -> true;
+ default -> false;
+ };
+ }
+}
diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/pdf/ua/MarkedContentInjector.java b/app/proprietary/src/main/java/stirling/software/proprietary/pdf/ua/MarkedContentInjector.java
new file mode 100644
index 0000000000..c9d6330a60
--- /dev/null
+++ b/app/proprietary/src/main/java/stirling/software/proprietary/pdf/ua/MarkedContentInjector.java
@@ -0,0 +1,284 @@
+package stirling.software.proprietary.pdf.ua;
+
+import java.io.IOException;
+import java.io.OutputStream;
+import java.util.ArrayDeque;
+import java.util.ArrayList;
+import java.util.Deque;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+
+import org.apache.pdfbox.contentstream.operator.Operator;
+import org.apache.pdfbox.cos.COSBase;
+import org.apache.pdfbox.cos.COSDictionary;
+import org.apache.pdfbox.cos.COSInteger;
+import org.apache.pdfbox.cos.COSName;
+import org.apache.pdfbox.pdfparser.PDFStreamParser;
+import org.apache.pdfbox.pdfwriter.ContentStreamWriter;
+import org.apache.pdfbox.pdmodel.PDDocument;
+import org.apache.pdfbox.pdmodel.PDPage;
+import org.apache.pdfbox.pdmodel.common.PDStream;
+
+import lombok.extern.slf4j.Slf4j;
+
+/**
+ * Rewrites a page stream so every markable operator sits inside a marked-content sequence: claimed
+ * content gets an MCID, everything else /Artifact, satisfying PDF/UA-1 clause 7.1 by construction.
+ */
+@Slf4j
+public class MarkedContentInjector {
+
+ private static final COSName ARTIFACT = COSName.getPDFName("Artifact");
+ private static final COSName MCID = COSName.getPDFName("MCID");
+ private static final COSName ACTUAL_TEXT = COSName.getPDFName("ActualText");
+ private static final COSName ALT = COSName.getPDFName("Alt");
+
+ /** Operators that force an open sequence to close so nesting stays legal. */
+ private static boolean isBoundary(String name) {
+ return "BT".equals(name) || "ET".equals(name) || "q".equals(name) || "Q".equals(name);
+ }
+
+ private static boolean isMarkedContentOperator(String name) {
+ return "BDC".equals(name) || "BMC".equals(name) || "EMC".equals(name);
+ }
+
+ /**
+ * Path-construction operators; ISO 32000-1 forbids marked content inside a path object, so a
+ * sequence wrapping a fill or stroke must open before the path starts.
+ */
+ private static boolean isPathConstruction(String name) {
+ return switch (name) {
+ case "m", "l", "c", "v", "y", "h", "re" -> true;
+ default -> false;
+ };
+ }
+
+ private static boolean opensMarkedContent(String name) {
+ return "BDC".equals(name) || "BMC".equals(name);
+ }
+
+ /**
+ * True for an optional-content sequence; stripping an {@code /OC} wrapper would make hidden
+ * layers such as watermarks or redaction overlays visible.
+ */
+ private static boolean isOptionalContent(String name, List operands) {
+ return opensMarkedContent(name)
+ && !operands.isEmpty()
+ && operands.get(0) instanceof COSName tag
+ && "OC".equals(tag.getName());
+ }
+
+ /**
+ * True when a sequence supplies replacement text for its glyphs; dropping it leaves a screen
+ * reader with the font's own mapping, which for a ligature says nothing useful.
+ */
+ private static boolean carriesReplacementText(String name, List operands) {
+ if (!opensMarkedContent(name)) {
+ return false;
+ }
+ for (COSBase operand : operands) {
+ if (operand instanceof COSDictionary properties
+ && (properties.containsKey(ACTUAL_TEXT)
+ || properties.containsKey(ALT)
+ || properties.containsKey(COSName.E))) {
+ return true;
+ }
+ }
+ return false;
+ }
+
+ /** The source's own ids mean nothing once the tree is rebuilt, so they are dropped. */
+ private static void stripStaleMcid(List operands) {
+ for (COSBase operand : operands) {
+ if (operand instanceof COSDictionary properties) {
+ properties.removeItem(MCID);
+ }
+ }
+ }
+
+ /** Wraps every markable operator on the page; returns the next unused marked content id. */
+ public int inject(
+ PDDocument document,
+ PDPage page,
+ List blocks,
+ int nextMcid,
+ boolean stripExisting)
+ throws IOException {
+
+ Map owners = ownersByOrdinal(blocks);
+ List