mirror of
https://github.com/arsvendg/Stirling-PDF.git
synced 2026-07-16 19:33:11 +02:00
PDF Text editor (#4724)
## Summary - add a `PdfJsonConversionService` that serializes PDF text, fonts, and metadata to JSON and rebuilds a PDF from the same structure - expose REST endpoints for `/pdf/json` and `/json/pdf` conversions using the existing convert API infrastructure - define JSON model classes capturing document metadata, font information, and positioned text elements ## Testing - `./gradlew spotlessApply` *(fails: plugin org.springframework.boot:3.5.4 unavailable in build environment)* - `./gradlew build` *(fails: plugin org.springframework.boot:3.5.4 unavailable in build environment)* ------ https://chatgpt.com/codex/tasks/task_b_68f8e98d94ac8328a0e499e541528b6f --------- Co-authored-by: EthanHealy01 <[email protected]>
This commit is contained in:
co-authored by
EthanHealy01
parent
d42065e338
commit
b0397da19e
Generated
+55
@@ -62,6 +62,7 @@
|
||||
"react": "^19.1.1",
|
||||
"react-dom": "^19.1.1",
|
||||
"react-i18next": "^15.7.3",
|
||||
"react-rnd": "^10.5.2",
|
||||
"react-router-dom": "^7.9.1",
|
||||
"signature_pad": "^5.0.4",
|
||||
"tailwindcss": "^4.1.13",
|
||||
@@ -12081,6 +12082,16 @@
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/re-resizable": {
|
||||
"version": "6.11.2",
|
||||
"resolved": "https://registry.npmjs.org/re-resizable/-/re-resizable-6.11.2.tgz",
|
||||
"integrity": "sha512-2xI2P3OHs5qw7K0Ud1aLILK6MQxW50TcO+DetD9eIV58j84TqYeHoZcL9H4GXFXXIh7afhH8mv5iUCXII7OW7A==",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"react": "^16.13.1 || ^17.0.0 || ^18.0.0 || ^19.0.0",
|
||||
"react-dom": "^16.13.1 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/react": {
|
||||
"version": "19.2.0",
|
||||
"resolved": "https://registry.npmjs.org/react/-/react-19.2.0.tgz",
|
||||
@@ -12102,6 +12113,29 @@
|
||||
"react": "^19.2.0"
|
||||
}
|
||||
},
|
||||
"node_modules/react-draggable": {
|
||||
"version": "4.4.6",
|
||||
"resolved": "https://registry.npmjs.org/react-draggable/-/react-draggable-4.4.6.tgz",
|
||||
"integrity": "sha512-LtY5Xw1zTPqHkVmtM3X8MUOxNDOUhv/khTgBgrUvwaS064bwVvxT+q5El0uUFNx5IEPKXuRejr7UqLwBIg5pdw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"clsx": "^1.1.1",
|
||||
"prop-types": "^15.8.1"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": ">= 16.3.0",
|
||||
"react-dom": ">= 16.3.0"
|
||||
}
|
||||
},
|
||||
"node_modules/react-draggable/node_modules/clsx": {
|
||||
"version": "1.2.1",
|
||||
"resolved": "https://registry.npmjs.org/clsx/-/clsx-1.2.1.tgz",
|
||||
"integrity": "sha512-EcR6r5a8bj6pu3ycsa/E/cKVGuTgZJZdsyUYHOksG/UHIiKfjxzRxYJpyVBwYaQeOvghal9fcc4PidlgzugAQg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/react-dropzone": {
|
||||
"version": "14.3.8",
|
||||
"resolved": "https://registry.npmjs.org/react-dropzone/-/react-dropzone-14.3.8.tgz",
|
||||
@@ -12220,6 +12254,27 @@
|
||||
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
|
||||
"license": "0BSD"
|
||||
},
|
||||
"node_modules/react-rnd": {
|
||||
"version": "10.5.2",
|
||||
"resolved": "https://registry.npmjs.org/react-rnd/-/react-rnd-10.5.2.tgz",
|
||||
"integrity": "sha512-0Tm4x7k7pfHf2snewJA8x7Nwgt3LV+58MVEWOVsFjk51eYruFEa6Wy7BNdxt4/lH0wIRsu7Gm3KjSXY2w7YaNw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"re-resizable": "6.11.2",
|
||||
"react-draggable": "4.4.6",
|
||||
"tslib": "2.6.2"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": ">=16.3.0",
|
||||
"react-dom": ">=16.3.0"
|
||||
}
|
||||
},
|
||||
"node_modules/react-rnd/node_modules/tslib": {
|
||||
"version": "2.6.2",
|
||||
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.6.2.tgz",
|
||||
"integrity": "sha512-AEYxH93jGFPn/a2iVAwW87VuUIkR1FVUKB77NwMF7nBTDkDrrT/Hpt/IrCJ0QXhW27jTBDcf5ZY7w6RiqTMw2Q==",
|
||||
"license": "0BSD"
|
||||
},
|
||||
"node_modules/react-router": {
|
||||
"version": "7.9.4",
|
||||
"resolved": "https://registry.npmjs.org/react-router/-/react-router-7.9.4.tgz",
|
||||
|
||||
@@ -57,6 +57,7 @@
|
||||
"posthog-js": "^1.268.0",
|
||||
"react": "^19.1.1",
|
||||
"react-dom": "^19.1.1",
|
||||
"react-rnd": "^10.5.2",
|
||||
"react-i18next": "^15.7.3",
|
||||
"react-router-dom": "^7.9.1",
|
||||
"signature_pad": "^5.0.4",
|
||||
|
||||
@@ -28,7 +28,10 @@
|
||||
"toggle": {
|
||||
"fullscreen": "Switch to fullscreen mode",
|
||||
"sidebar": "Switch to sidebar mode"
|
||||
}
|
||||
},
|
||||
"alpha": "Alpha",
|
||||
"premiumFeature": "Premium feature:",
|
||||
"comingSoon": "Coming soon:"
|
||||
},
|
||||
"unsavedChanges": "You have unsaved changes to your PDF.",
|
||||
"areYouSure": "Are you sure you want to leave?",
|
||||
@@ -856,6 +859,10 @@
|
||||
"desc": "Overlay one PDF on top of another",
|
||||
"title": "Overlay PDFs"
|
||||
},
|
||||
"pdfTextEditor": {
|
||||
"title": "PDF Text Editor",
|
||||
"desc": "Review and edit Stirling PDF JSON exports with grouped text editing and PDF regeneration"
|
||||
},
|
||||
"addText": {
|
||||
"tags": "text,annotation,label",
|
||||
"title": "Add Text",
|
||||
@@ -5789,6 +5796,128 @@
|
||||
"noShortcut": "No shortcut set"
|
||||
}
|
||||
},
|
||||
"pdfTextEditor": {
|
||||
"title": "PDF JSON Editor",
|
||||
"viewLabel": "PDF Editor",
|
||||
"converting": "Converting PDF to editable format...",
|
||||
"conversionFailed": "Failed to convert PDF. Please try again.",
|
||||
"currentFile": "Current file: {{name}}",
|
||||
"pageSummary": "Page {{number}} of {{total}}",
|
||||
"pagePreviewAlt": "Page preview",
|
||||
"imageLabel": "Placed image",
|
||||
"noTextOnPage": "No editable text was detected on this page.",
|
||||
"pageType": {
|
||||
"paragraph": "Paragraph page",
|
||||
"sparse": "Sparse text"
|
||||
},
|
||||
"groupingMode": {
|
||||
"auto": "Auto",
|
||||
"paragraph": "Paragraph",
|
||||
"singleLine": "Single Line"
|
||||
},
|
||||
"badges": {
|
||||
"unsaved": "Edited",
|
||||
"modified": "Edited",
|
||||
"earlyAccess": "Early Access"
|
||||
},
|
||||
"actions": {
|
||||
"reset": "Reset Changes",
|
||||
"downloadJson": "Download JSON",
|
||||
"generatePdf": "Generate PDF"
|
||||
},
|
||||
"options": {
|
||||
"autoScaleText": {
|
||||
"title": "Auto-scale text to fit boxes",
|
||||
"description": "Automatically scales text horizontally to fit within its original bounding box when font rendering differs from PDF."
|
||||
},
|
||||
"groupingMode": {
|
||||
"title": "Text Grouping Mode",
|
||||
"autoDescription": "Automatically detects page type and groups text appropriately.",
|
||||
"paragraphDescription": "Groups aligned lines into multi-line paragraph text boxes.",
|
||||
"singleLineDescription": "Keeps each PDF text line as a separate text box."
|
||||
},
|
||||
"manualGrouping": {
|
||||
"descriptionInline": "Tip: Hold Ctrl (Cmd) or Shift to multi-select text boxes. A floating toolbar will appear above the selection so you can merge, ungroup, or adjust widths."
|
||||
},
|
||||
"forceSingleElement": {
|
||||
"title": "Lock edited text to a single PDF element",
|
||||
"description": "When enabled, the editor exports each edited text box as one PDF text element to avoid overlapping glyphs or mixed fonts."
|
||||
}
|
||||
},
|
||||
"manual": {
|
||||
"mergeTooltip": "Merge selected boxes",
|
||||
"merge": "Merge selection",
|
||||
"ungroupTooltip": "Split paragraph back into lines",
|
||||
"ungroup": "Ungroup selection",
|
||||
"widthMenu": "Width options",
|
||||
"expandWidth": "Expand to page edge",
|
||||
"resetWidth": "Reset width",
|
||||
"resizeHandle": "Adjust text width"
|
||||
},
|
||||
"disclaimer": {
|
||||
"heading": "Preview Limitations",
|
||||
"textFocus": "This workspace focuses on editing text and repositioning embedded images. Complex page artwork, form widgets, and layered graphics are preserved for export but are not fully editable here.",
|
||||
"previewVariance": "Some visuals (such as table borders, shapes, or annotation appearances) may not display exactly in the preview. The exported PDF keeps the original drawing commands whenever possible.",
|
||||
"alpha": "This alpha viewer is still evolving—certain fonts, colours, transparency effects, and layout details may shift slightly. Please double-check the generated PDF before sharing."
|
||||
},
|
||||
"empty": {
|
||||
"title": "No document loaded",
|
||||
"subtitle": "Load a PDF or JSON file to begin editing text content."
|
||||
},
|
||||
"welcomeBanner": {
|
||||
"title": "Welcome to PDF Text Editor (Early Access)",
|
||||
"experimental": "This is an experimental feature in active development. Expect some instability and issues during use.",
|
||||
"howItWorks": "This tool converts your PDF to an editable format where you can modify text content and reposition images. Changes are saved back as a new PDF.",
|
||||
"bestFor": "Works Best With:",
|
||||
"bestFor1": "Simple PDFs containing primarily text and images",
|
||||
"bestFor2": "Documents with standard paragraph formatting",
|
||||
"bestFor3": "Letters, essays, reports, and basic documents",
|
||||
"notIdealFor": "Not Ideal For:",
|
||||
"notIdealFor1": "PDFs with special formatting like bullet points, tables, or multi-column layouts",
|
||||
"notIdealFor2": "Magazines, brochures, or heavily designed documents",
|
||||
"notIdealFor3": "Instruction manuals with complex layouts",
|
||||
"limitations": "Current Limitations:",
|
||||
"limitation1": "Font rendering may differ slightly from the original PDF",
|
||||
"limitation2": "Complex graphics, form fields, and annotations are preserved but not editable",
|
||||
"limitation3": "Large files may take time to convert and process",
|
||||
"knownIssues": "Known Issues (Being Fixed):",
|
||||
"issue1": "Text colour is not currently preserved (will be added soon)",
|
||||
"issue2": "Paragraph mode has more alignment and spacing issues - Single Line mode recommended",
|
||||
"issue3": "The preview display differs from the exported PDF - exported PDFs are closer to the original",
|
||||
"issue4": "Rotated text alignment may need manual adjustment",
|
||||
"issue5": "Transparency and layering effects may vary from original",
|
||||
"feedback": "This is an early access feature. Please report any issues you encounter to help us improve!",
|
||||
"gotIt": "Got it",
|
||||
"dontShowAgain": "Don't show again"
|
||||
},
|
||||
"modeChange": {
|
||||
"title": "Confirm Mode Change",
|
||||
"warning": "Changing the text grouping mode will reset all unsaved changes. Are you sure you want to continue?",
|
||||
"cancel": "Cancel",
|
||||
"confirm": "Reset and Change Mode"
|
||||
},
|
||||
"fontAnalysis": {
|
||||
"details": "Font Details",
|
||||
"embedded": "Embedded",
|
||||
"type": "Type",
|
||||
"webFormat": "Web Format",
|
||||
"warnings": "Warnings",
|
||||
"suggestions": "Notes",
|
||||
"currentPageFonts": "Fonts on this page",
|
||||
"allFonts": "All fonts",
|
||||
"fallback": "fallback",
|
||||
"missing": "missing",
|
||||
"perfectMessage": "All fonts can be reproduced perfectly.",
|
||||
"warningMessage": "Some fonts may not render correctly.",
|
||||
"infoMessage": "Font reproduction information available.",
|
||||
"perfect": "perfect",
|
||||
"subset": "subset"
|
||||
},
|
||||
"errors": {
|
||||
"invalidJson": "Unable to read the JSON file. Ensure it was generated by the PDF to JSON tool.",
|
||||
"pdfConversion": "Unable to convert the edited JSON back into a PDF."
|
||||
}
|
||||
},
|
||||
"auth": {
|
||||
"sessionExpired": "Session Expired",
|
||||
"pleaseLoginAgain": "Please login again.",
|
||||
|
||||
@@ -20,11 +20,11 @@ export const getFontFamily = (alphabet: string): string => {
|
||||
case 'arabic':
|
||||
return 'Noto Sans Arabic, Arial Unicode MS, sans-serif';
|
||||
case 'japanese':
|
||||
return 'Meiryo, Yu Gothic, Hiragino Sans, sans-serif';
|
||||
return 'Noto Sans JP, Yu Gothic, Hiragino Sans, sans-serif';
|
||||
case 'korean':
|
||||
return 'Malgun Gothic, Dotum, sans-serif';
|
||||
return 'Noto Sans KR, Malgun Gothic, Dotum, sans-serif';
|
||||
case 'chinese':
|
||||
return 'SimSun, Microsoft YaHei, sans-serif';
|
||||
return 'Noto Sans SC, Microsoft YaHei, SimSun, sans-serif';
|
||||
case 'thai':
|
||||
return 'Noto Sans Thai, Tahoma, sans-serif';
|
||||
case 'roman':
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import React from 'react';
|
||||
import { Text } from '@mantine/core';
|
||||
import { Text, Badge } from '@mantine/core';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Tooltip } from '@app/components/shared/Tooltip';
|
||||
import HotkeyDisplay from '@app/components/hotkeys/HotkeyDisplay';
|
||||
@@ -57,9 +57,20 @@ const CompactToolItem: React.FC<CompactToolItemProps> = ({ id, tool, isSelected,
|
||||
</span>
|
||||
) : null}
|
||||
<span className="tool-panel__fullscreen-list-body">
|
||||
<Text fw={600} size="sm" className="tool-panel__fullscreen-name">
|
||||
{tool.name}
|
||||
</Text>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '0.5rem' }}>
|
||||
<Text fw={600} size="sm" className="tool-panel__fullscreen-name">
|
||||
{tool.name}
|
||||
</Text>
|
||||
{tool.versionStatus === 'alpha' && (
|
||||
<Badge
|
||||
size="xs"
|
||||
variant="light"
|
||||
color="orange"
|
||||
>
|
||||
{t('toolPanel.alpha', 'Alpha')}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
</span>
|
||||
{!disabled && (
|
||||
<div className="tool-panel__fullscreen-star-compact">
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import React from 'react';
|
||||
import { Text } from '@mantine/core';
|
||||
import { Text, Badge } from '@mantine/core';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import HotkeyDisplay from '@app/components/hotkeys/HotkeyDisplay';
|
||||
import FavoriteStar from '@app/components/tools/toolPicker/FavoriteStar';
|
||||
@@ -59,9 +59,21 @@ const DetailedToolItem: React.FC<DetailedToolItemProps> = ({ id, tool, isSelecte
|
||||
</span>
|
||||
) : null}
|
||||
<span className="tool-panel__fullscreen-body">
|
||||
<Text fw={600} size="sm" className="tool-panel__fullscreen-name">
|
||||
{tool.name}
|
||||
</Text>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '0.5rem' }}>
|
||||
<Text fw={600} size="sm" className="tool-panel__fullscreen-name">
|
||||
{tool.name}
|
||||
</Text>
|
||||
{tool.versionStatus === 'alpha' && (
|
||||
<Badge
|
||||
size="xs"
|
||||
variant="light"
|
||||
color="orange"
|
||||
>
|
||||
{/* we can add more translations for different badges in future, like beta, etc. */}
|
||||
{t('toolPanel.alpha', 'Alpha')}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
<Text size="sm" c="dimmed" className="tool-panel__fullscreen-description">
|
||||
{disabled ? (
|
||||
<>
|
||||
|
||||
@@ -3,6 +3,7 @@ import { useToolWorkflow } from '@app/contexts/ToolWorkflowContext';
|
||||
import { ToolRegistryEntry } from '@app/data/toolsTaxonomy';
|
||||
import { ToolId } from '@app/types/toolId';
|
||||
import type { ToolAvailabilityMap } from '@app/hooks/useToolManagement';
|
||||
import { useAppConfig } from '@app/contexts/AppConfigContext';
|
||||
|
||||
export const getItemClasses = (isDetailed: boolean): string => {
|
||||
return isDetailed ? 'tool-panel__fullscreen-item--detailed' : '';
|
||||
@@ -23,17 +24,23 @@ export const getIconStyle = (): Record<string, string> => {
|
||||
return {};
|
||||
};
|
||||
|
||||
export type ToolDisabledReason = 'comingSoon' | 'disabledByAdmin' | 'missingDependency' | 'unknownUnavailable' | null;
|
||||
export type ToolDisabledReason = 'comingSoon' | 'disabledByAdmin' | 'missingDependency' | 'unknownUnavailable' | 'requiresPremium' | null;
|
||||
|
||||
export const getToolDisabledReason = (
|
||||
id: string,
|
||||
tool: ToolRegistryEntry,
|
||||
toolAvailability?: ToolAvailabilityMap
|
||||
toolAvailability?: ToolAvailabilityMap,
|
||||
premiumEnabled?: boolean
|
||||
): ToolDisabledReason => {
|
||||
if (!tool.component && !tool.link && id !== 'read' && id !== 'multiTool') {
|
||||
return 'comingSoon';
|
||||
}
|
||||
|
||||
// Check if tool requires premium but premium is not enabled
|
||||
if (tool.requiresPremium === true && premiumEnabled !== true) {
|
||||
return 'requiresPremium';
|
||||
}
|
||||
|
||||
const availabilityInfo = toolAvailability?.[id as ToolId];
|
||||
if (availabilityInfo && availabilityInfo.available === false) {
|
||||
if (availabilityInfo.reason === 'missingDependency') {
|
||||
@@ -51,6 +58,12 @@ export const getToolDisabledReason = (
|
||||
export const getDisabledLabel = (
|
||||
disabledReason: ToolDisabledReason
|
||||
): { key: string; fallback: string } => {
|
||||
if (disabledReason === 'requiresPremium') {
|
||||
return {
|
||||
key: 'toolPanel.premiumFeature',
|
||||
fallback: 'Premium feature:'
|
||||
};
|
||||
}
|
||||
if (disabledReason === 'missingDependency') {
|
||||
return {
|
||||
key: 'toolPanel.fullscreen.unavailableDependency',
|
||||
@@ -72,10 +85,12 @@ export const getDisabledLabel = (
|
||||
export function useToolMeta(id: string, tool: ToolRegistryEntry) {
|
||||
const { hotkeys } = useHotkeys();
|
||||
const { isFavorite, toggleFavorite, toolAvailability } = useToolWorkflow();
|
||||
const { config } = useAppConfig();
|
||||
const premiumEnabled = config?.premiumEnabled;
|
||||
|
||||
const isFav = isFavorite(id as ToolId);
|
||||
const binding = hotkeys[id as ToolId];
|
||||
const disabledReason = getToolDisabledReason(id, tool, toolAvailability);
|
||||
const disabledReason = getToolDisabledReason(id, tool, toolAvailability, premiumEnabled);
|
||||
const disabled = disabledReason !== null;
|
||||
|
||||
return {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import React from "react";
|
||||
import { Button } from "@mantine/core";
|
||||
import { Button, Badge } from "@mantine/core";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Tooltip } from "@app/components/shared/Tooltip";
|
||||
import { ToolIcon } from "@app/components/shared/ToolIcon";
|
||||
@@ -13,6 +13,7 @@ import FavoriteStar from "@app/components/tools/toolPicker/FavoriteStar";
|
||||
import { useToolWorkflow } from "@app/contexts/ToolWorkflowContext";
|
||||
import { ToolId } from "@app/types/toolId";
|
||||
import { getToolDisabledReason, getDisabledLabel } from "@app/components/tools/fullscreen/shared";
|
||||
import { useAppConfig } from "@app/contexts/AppConfigContext";
|
||||
|
||||
interface ToolButtonProps {
|
||||
id: ToolId;
|
||||
@@ -27,8 +28,10 @@ interface ToolButtonProps {
|
||||
|
||||
const ToolButton: React.FC<ToolButtonProps> = ({ id, tool, isSelected, onSelect, disableNavigation = false, matchedSynonym, hasStars = false }) => {
|
||||
const { t } = useTranslation();
|
||||
const { config } = useAppConfig();
|
||||
const premiumEnabled = config?.premiumEnabled;
|
||||
const { isFavorite, toggleFavorite, toolAvailability } = useToolWorkflow();
|
||||
const disabledReason = getToolDisabledReason(id, tool, toolAvailability);
|
||||
const disabledReason = getToolDisabledReason(id, tool, toolAvailability, premiumEnabled);
|
||||
const isUnavailable = disabledReason !== null;
|
||||
const { hotkeys } = useHotkeys();
|
||||
const binding = hotkeys[id];
|
||||
@@ -77,13 +80,25 @@ const ToolButton: React.FC<ToolButtonProps> = ({ id, tool, isSelected, onSelect,
|
||||
opacity={isUnavailable ? 0.25 : 1}
|
||||
/>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'flex-start', flex: 1, overflow: 'visible' }}>
|
||||
<FitText
|
||||
text={tool.name}
|
||||
lines={1}
|
||||
minimumFontScale={0.8}
|
||||
as="span"
|
||||
style={{ display: 'inline-block', maxWidth: '100%', opacity: isUnavailable ? 0.25 : 1 }}
|
||||
/>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '0.5rem', width: '100%' }}>
|
||||
<FitText
|
||||
text={tool.name}
|
||||
lines={1}
|
||||
minimumFontScale={0.8}
|
||||
as="span"
|
||||
style={{ display: 'inline-block', maxWidth: '100%', opacity: isUnavailable ? 0.25 : 1 }}
|
||||
/>
|
||||
{tool.versionStatus === 'alpha' && (
|
||||
<Badge
|
||||
size="xs"
|
||||
variant="light"
|
||||
color="orange"
|
||||
style={{ flexShrink: 0, opacity: isUnavailable ? 0.25 : 1 }}
|
||||
>
|
||||
{t('toolPanel.alpha', 'Alpha')}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
{matchedSynonym && (
|
||||
<span style={{
|
||||
fontSize: '0.75rem',
|
||||
|
||||
@@ -33,7 +33,9 @@ export const CONVERSION_ENDPOINTS = {
|
||||
'pdf-pdfa': '/api/v1/convert/pdf/pdfa',
|
||||
'html-pdf': '/api/v1/convert/html/pdf',
|
||||
'markdown-pdf': '/api/v1/convert/markdown/pdf',
|
||||
'eml-pdf': '/api/v1/convert/eml/pdf'
|
||||
'eml-pdf': '/api/v1/convert/eml/pdf',
|
||||
'pdf-text-editor': '/api/v1/convert/pdf/text-editor',
|
||||
'text-editor-pdf': '/api/v1/convert/text-editor/pdf'
|
||||
} as const;
|
||||
|
||||
export const ENDPOINT_NAMES = {
|
||||
@@ -52,7 +54,9 @@ export const ENDPOINT_NAMES = {
|
||||
'pdf-pdfa': 'pdf-to-pdfa',
|
||||
'html-pdf': 'html-to-pdf',
|
||||
'markdown-pdf': 'markdown-to-pdf',
|
||||
'eml-pdf': 'eml-to-pdf'
|
||||
'eml-pdf': 'eml-to-pdf',
|
||||
'pdf-text-editor': 'pdf-to-text-editor',
|
||||
'text-editor-pdf': 'text-editor-to-pdf'
|
||||
} as const;
|
||||
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@ export const CONVERT_SUPPORTED_FORMATS = [
|
||||
// OpenDocument
|
||||
'odt', 'ott', 'ods', 'ots', 'odp', 'otp', 'odg', 'otg',
|
||||
// Text formats
|
||||
'txt', 'text', 'xml', 'rtf', 'html', 'lwp', 'md',
|
||||
'txt', 'text', 'xml', 'rtf', 'html', 'lwp', 'md', 'json',
|
||||
// Images
|
||||
'bmp', 'gif', 'jpeg', 'jpg', 'png', 'tif', 'tiff', 'pbm', 'pgm', 'ppm', 'ras', 'xbm', 'xpm', 'svg', 'svm', 'wmf', 'webp',
|
||||
// StarOffice
|
||||
|
||||
@@ -121,10 +121,11 @@ export const NavigationProvider: React.FC<{
|
||||
hasUnsavedChanges
|
||||
});
|
||||
|
||||
// If we're leaving pageEditor or viewer workbench and have unsaved changes, request navigation
|
||||
// If we're leaving pageEditor, viewer, or custom workbench and have unsaved changes, request navigation
|
||||
const leavingWorkbenchWithChanges =
|
||||
(state.workbench === 'pageEditor' && workbench !== 'pageEditor' && hasUnsavedChanges) ||
|
||||
(state.workbench === 'viewer' && workbench !== 'viewer' && hasUnsavedChanges);
|
||||
(state.workbench === 'viewer' && workbench !== 'viewer' && hasUnsavedChanges) ||
|
||||
(state.workbench.startsWith('custom:') && workbench !== state.workbench && hasUnsavedChanges);
|
||||
|
||||
if (leavingWorkbenchWithChanges) {
|
||||
// Update state to reflect unsaved changes so modal knows
|
||||
@@ -132,7 +133,19 @@ export const NavigationProvider: React.FC<{
|
||||
dispatch({ type: 'SET_UNSAVED_CHANGES', payload: { hasChanges: true } });
|
||||
}
|
||||
const performWorkbenchChange = () => {
|
||||
dispatch({ type: 'SET_WORKBENCH', payload: { workbench } });
|
||||
// When leaving a custom workbench, clear the selected tool
|
||||
console.log('[NavigationContext] performWorkbenchChange executing', {
|
||||
from: state.workbench,
|
||||
to: workbench,
|
||||
isCustom: state.workbench.startsWith('custom:')
|
||||
});
|
||||
if (state.workbench.startsWith('custom:')) {
|
||||
console.log('[NavigationContext] Clearing tool and changing workbench to:', workbench);
|
||||
dispatch({ type: 'SET_TOOL_AND_WORKBENCH', payload: { toolId: null, workbench } });
|
||||
} else {
|
||||
console.log('[NavigationContext] Just changing workbench to:', workbench);
|
||||
dispatch({ type: 'SET_WORKBENCH', payload: { workbench } });
|
||||
}
|
||||
};
|
||||
dispatch({ type: 'SET_PENDING_NAVIGATION', payload: { navigationFn: performWorkbenchChange } });
|
||||
dispatch({ type: 'SHOW_NAVIGATION_WARNING', payload: { show: true } });
|
||||
@@ -149,10 +162,11 @@ export const NavigationProvider: React.FC<{
|
||||
// Check for unsaved changes using registered checker or state
|
||||
const hasUnsavedChanges = unsavedChangesCheckerRef.current?.() || state.hasUnsavedChanges;
|
||||
|
||||
// If we're leaving pageEditor or viewer workbench and have unsaved changes, request navigation
|
||||
// If we're leaving pageEditor, viewer, or custom workbench and have unsaved changes, request navigation
|
||||
const leavingWorkbenchWithChanges =
|
||||
(state.workbench === 'pageEditor' && workbench !== 'pageEditor' && hasUnsavedChanges) ||
|
||||
(state.workbench === 'viewer' && workbench !== 'viewer' && hasUnsavedChanges);
|
||||
(state.workbench === 'viewer' && workbench !== 'viewer' && hasUnsavedChanges) ||
|
||||
(state.workbench.startsWith('custom:') && workbench !== state.workbench && hasUnsavedChanges);
|
||||
|
||||
if (leavingWorkbenchWithChanges) {
|
||||
const performWorkbenchChange = () => {
|
||||
@@ -192,13 +206,19 @@ export const NavigationProvider: React.FC<{
|
||||
}, [state.hasUnsavedChanges]),
|
||||
|
||||
confirmNavigation: useCallback(() => {
|
||||
console.log('[NavigationContext] confirmNavigation called', {
|
||||
hasPendingNav: !!state.pendingNavigation,
|
||||
currentWorkbench: state.workbench,
|
||||
currentTool: state.selectedTool
|
||||
});
|
||||
if (state.pendingNavigation) {
|
||||
state.pendingNavigation();
|
||||
}
|
||||
|
||||
dispatch({ type: 'SET_PENDING_NAVIGATION', payload: { navigationFn: null } });
|
||||
dispatch({ type: 'SHOW_NAVIGATION_WARNING', payload: { show: false } });
|
||||
}, [state.pendingNavigation]),
|
||||
console.log('[NavigationContext] confirmNavigation completed');
|
||||
}, [state.pendingNavigation, state.workbench, state.selectedTool]),
|
||||
|
||||
cancelNavigation: useCallback(() => {
|
||||
dispatch({ type: 'SET_PENDING_NAVIGATION', payload: { navigationFn: null } });
|
||||
|
||||
@@ -59,6 +59,10 @@ export type ToolRegistryEntry = {
|
||||
supportsAutomate?: boolean;
|
||||
// Synonyms for search (optional)
|
||||
synonyms?: string[];
|
||||
// Version status indicator (e.g., "alpha", "beta")
|
||||
versionStatus?: "alpha" | "beta";
|
||||
// Whether this tool requires premium access
|
||||
requiresPremium?: boolean;
|
||||
}
|
||||
|
||||
export type RegularToolRegistry = Record<RegularToolId, ToolRegistryEntry>;
|
||||
|
||||
@@ -8,6 +8,7 @@ import { parseToolRoute, updateToolRoute, clearToolRoute } from '@app/utils/urlR
|
||||
import { ToolRegistry } from '@app/data/toolsTaxonomy';
|
||||
import { firePixel } from '@app/utils/scarfTracking';
|
||||
import { withBasePath } from '@app/constants/app';
|
||||
import { useAppConfig } from '@app/contexts/AppConfigContext';
|
||||
|
||||
/**
|
||||
* Hook to sync workbench and tool with URL using registry
|
||||
@@ -19,11 +20,33 @@ export function useNavigationUrlSync(
|
||||
registry: ToolRegistry,
|
||||
enableSync: boolean = true
|
||||
) {
|
||||
const { config } = useAppConfig();
|
||||
const premiumEnabled = config?.premiumEnabled;
|
||||
const hasInitialized = useRef(false);
|
||||
const prevSelectedTool = useRef<ToolId | null>(null);
|
||||
|
||||
// Check if tool requires premium and redirect if needed
|
||||
const checkPremiumAndSelect = useCallback((toolId: ToolId) => {
|
||||
const tool = registry[toolId];
|
||||
if (tool?.requiresPremium === true && premiumEnabled !== true) {
|
||||
// Premium tool accessed without premium - redirect to home
|
||||
const homePath = withBasePath('/');
|
||||
if (window.location.pathname !== homePath) {
|
||||
clearToolRoute(true); // Use replaceState to avoid adding to history
|
||||
window.location.href = homePath;
|
||||
}
|
||||
return;
|
||||
}
|
||||
handleToolSelect(toolId);
|
||||
}, [registry, premiumEnabled, handleToolSelect]);
|
||||
|
||||
// Initialize workbench and tool from URL on mount
|
||||
useEffect(() => {
|
||||
if (!enableSync) return;
|
||||
// Wait for config to load before checking premium status
|
||||
if (config === null) return;
|
||||
// Only run once on initial mount
|
||||
if (hasInitialized.current) return;
|
||||
|
||||
// Fire pixel for initial page load
|
||||
const currentPath = window.location.pathname;
|
||||
@@ -32,7 +55,7 @@ export function useNavigationUrlSync(
|
||||
const route = parseToolRoute(registry);
|
||||
if (route.toolId !== selectedTool) {
|
||||
if (route.toolId) {
|
||||
handleToolSelect(route.toolId);
|
||||
checkPremiumAndSelect(route.toolId);
|
||||
} else if (selectedTool !== null) {
|
||||
// Only clear selection if we actually had a tool selected
|
||||
// Don't clear on initial load when selectedTool starts as null
|
||||
@@ -41,7 +64,7 @@ export function useNavigationUrlSync(
|
||||
}
|
||||
|
||||
hasInitialized.current = true;
|
||||
}, []); // Only run on mount
|
||||
}, [checkPremiumAndSelect, config, enableSync, registry, selectedTool]); // Include dependencies
|
||||
|
||||
// Update URL when tool or workbench changes
|
||||
useEffect(() => {
|
||||
@@ -73,7 +96,7 @@ export function useNavigationUrlSync(
|
||||
firePixel(currentPath);
|
||||
|
||||
if (route.toolId) {
|
||||
handleToolSelect(route.toolId);
|
||||
checkPremiumAndSelect(route.toolId);
|
||||
} else {
|
||||
clearToolSelection();
|
||||
}
|
||||
@@ -82,7 +105,7 @@ export function useNavigationUrlSync(
|
||||
|
||||
window.addEventListener('popstate', handlePopState);
|
||||
return () => window.removeEventListener('popstate', handlePopState);
|
||||
}, [selectedTool, handleToolSelect, clearToolSelection, registry, enableSync]);
|
||||
}, [selectedTool, handleToolSelect, clearToolSelection, registry, enableSync, checkPremiumAndSelect]);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -45,8 +45,8 @@ function normalizeRedirectPath(target?: string): string {
|
||||
function persistRedirectPath(path: string): void {
|
||||
try {
|
||||
document.cookie = `${OAUTH_REDIRECT_COOKIE}=${encodeURIComponent(path)}; path=/; max-age=${OAUTH_REDIRECT_COOKIE_MAX_AGE}; SameSite=Lax`;
|
||||
} catch (error) {
|
||||
console.warn('[SpringAuth] Failed to persist OAuth redirect path', error);
|
||||
} catch (_error) {
|
||||
// console.warn('[SpringAuth] Failed to persist OAuth redirect path', _error);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -123,13 +123,13 @@ class SpringAuthClient {
|
||||
const token = localStorage.getItem('stirling_jwt');
|
||||
|
||||
if (!token) {
|
||||
console.debug('[SpringAuth] getSession: No JWT in localStorage');
|
||||
// console.debug('[SpringAuth] getSession: No JWT in localStorage');
|
||||
return { data: { session: null }, error: null };
|
||||
}
|
||||
|
||||
// Verify with backend
|
||||
// Note: We pass the token explicitly here, overriding the interceptor's default
|
||||
console.debug('[SpringAuth] getSession: Verifying JWT with /api/v1/auth/me');
|
||||
// console.debug('[SpringAuth] getSession: Verifying JWT with /api/v1/auth/me');
|
||||
const response = await apiClient.get('/api/v1/auth/me', {
|
||||
headers: {
|
||||
'Authorization': `Bearer ${token}`,
|
||||
@@ -137,9 +137,9 @@ class SpringAuthClient {
|
||||
suppressErrorToast: true, // Suppress global error handler (we handle errors locally)
|
||||
});
|
||||
|
||||
console.debug('[SpringAuth] /me response status:', response.status);
|
||||
// console.debug('[SpringAuth] /me response status:', response.status);
|
||||
const data = response.data;
|
||||
console.debug('[SpringAuth] /me response data:', data);
|
||||
// console.debug('[SpringAuth] /me response data:', data);
|
||||
|
||||
// Create session object
|
||||
const session: Session = {
|
||||
@@ -149,7 +149,7 @@ class SpringAuthClient {
|
||||
expires_at: Date.now() + 3600 * 1000,
|
||||
};
|
||||
|
||||
console.debug('[SpringAuth] getSession: Session retrieved successfully');
|
||||
// console.debug('[SpringAuth] getSession: Session retrieved successfully');
|
||||
return { data: { session }, error: null };
|
||||
} catch (error: unknown) {
|
||||
console.error('[SpringAuth] getSession error:', error);
|
||||
@@ -190,7 +190,7 @@ class SpringAuthClient {
|
||||
|
||||
// Store JWT in localStorage
|
||||
localStorage.setItem('stirling_jwt', token);
|
||||
console.log('[SpringAuth] JWT stored in localStorage');
|
||||
// console.log('[SpringAuth] JWT stored in localStorage');
|
||||
|
||||
// Dispatch custom event for other components to react to JWT availability
|
||||
window.dispatchEvent(new CustomEvent('jwt-available'));
|
||||
@@ -261,7 +261,7 @@ class SpringAuthClient {
|
||||
|
||||
// Redirect to Spring OAuth2 endpoint (Vite will proxy to backend)
|
||||
const redirectUrl = `/oauth2/authorization/${params.provider}`;
|
||||
console.log('[SpringAuth] Redirecting to OAuth:', redirectUrl);
|
||||
// console.log('[SpringAuth] Redirecting to OAuth:', redirectUrl);
|
||||
// Use window.location.assign for full page navigation
|
||||
window.location.assign(redirectUrl);
|
||||
return { error: null };
|
||||
@@ -285,7 +285,7 @@ class SpringAuthClient {
|
||||
});
|
||||
|
||||
if (response.status === 200) {
|
||||
console.debug('[SpringAuth] signOut: Success');
|
||||
// console.debug('[SpringAuth] signOut: Success');
|
||||
}
|
||||
|
||||
// Clean up local storage
|
||||
@@ -401,7 +401,7 @@ class SpringAuthClient {
|
||||
|
||||
// Refresh if token expires soon
|
||||
if (timeUntilExpiry > 0 && timeUntilExpiry < this.TOKEN_REFRESH_THRESHOLD) {
|
||||
console.log('[SpringAuth] Proactively refreshing token');
|
||||
// console.log('[SpringAuth] Proactively refreshing token');
|
||||
await this.refreshSession();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,286 @@
|
||||
import React, { useMemo, useState } from 'react';
|
||||
import {
|
||||
Accordion,
|
||||
Badge,
|
||||
Box,
|
||||
Code,
|
||||
Collapse,
|
||||
Group,
|
||||
List,
|
||||
Paper,
|
||||
Stack,
|
||||
Text,
|
||||
Tooltip,
|
||||
} from '@mantine/core';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import CheckCircleIcon from '@mui/icons-material/CheckCircle';
|
||||
import WarningIcon from '@mui/icons-material/Warning';
|
||||
import ErrorIcon from '@mui/icons-material/Error';
|
||||
import InfoIcon from '@mui/icons-material/Info';
|
||||
import FontDownloadIcon from '@mui/icons-material/FontDownload';
|
||||
import ExpandMoreIcon from '@mui/icons-material/ExpandMore';
|
||||
import ExpandLessIcon from '@mui/icons-material/ExpandLess';
|
||||
|
||||
import { PdfJsonDocument } from '@app/tools/pdfTextEditor/pdfTextEditorTypes';
|
||||
import {
|
||||
analyzeDocumentFonts,
|
||||
DocumentFontAnalysis,
|
||||
FontAnalysis,
|
||||
getFontStatusColor,
|
||||
getFontStatusDescription,
|
||||
} from '@app/tools/pdfTextEditor/fontAnalysis';
|
||||
|
||||
interface FontStatusPanelProps {
|
||||
document: PdfJsonDocument | null;
|
||||
pageIndex?: number;
|
||||
}
|
||||
|
||||
const FontStatusBadge = ({ analysis }: { analysis: FontAnalysis }) => {
|
||||
const color = getFontStatusColor(analysis.status);
|
||||
const description = getFontStatusDescription(analysis.status);
|
||||
|
||||
const icon = useMemo(() => {
|
||||
switch (analysis.status) {
|
||||
case 'perfect':
|
||||
return <CheckCircleIcon sx={{ fontSize: 14 }} />;
|
||||
case 'embedded-subset':
|
||||
return <InfoIcon sx={{ fontSize: 14 }} />;
|
||||
case 'system-fallback':
|
||||
return <WarningIcon sx={{ fontSize: 14 }} />;
|
||||
case 'missing':
|
||||
return <ErrorIcon sx={{ fontSize: 14 }} />;
|
||||
default:
|
||||
return <InfoIcon sx={{ fontSize: 14 }} />;
|
||||
}
|
||||
}, [analysis.status]);
|
||||
|
||||
return (
|
||||
<Tooltip label={description} position="top" withArrow>
|
||||
<Badge
|
||||
size="xs"
|
||||
color={color}
|
||||
variant="light"
|
||||
leftSection={icon}
|
||||
style={{ cursor: 'help' }}
|
||||
>
|
||||
{analysis.status.replace('-', ' ')}
|
||||
</Badge>
|
||||
</Tooltip>
|
||||
);
|
||||
};
|
||||
|
||||
const FontDetailItem = ({ analysis }: { analysis: FontAnalysis }) => {
|
||||
const { t } = useTranslation();
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
|
||||
return (
|
||||
<Paper withBorder p="xs" style={{ cursor: 'pointer' }} onClick={() => setExpanded(!expanded)}>
|
||||
<Stack gap={4}>
|
||||
<Group justify="space-between">
|
||||
<Group gap={4}>
|
||||
<FontDownloadIcon sx={{ fontSize: 16 }} />
|
||||
<Text size="xs" fw={500} lineClamp={1}>
|
||||
{analysis.baseName}
|
||||
</Text>
|
||||
{analysis.isSubset && (
|
||||
<Badge size="xs" color="gray" variant="outline">
|
||||
subset
|
||||
</Badge>
|
||||
)}
|
||||
</Group>
|
||||
<Group gap={4}>
|
||||
<FontStatusBadge analysis={analysis} />
|
||||
{expanded ? <ExpandLessIcon sx={{ fontSize: 16 }} /> : <ExpandMoreIcon sx={{ fontSize: 16 }} />}
|
||||
</Group>
|
||||
</Group>
|
||||
|
||||
<Collapse in={expanded}>
|
||||
<Stack gap={4} mt={4}>
|
||||
{/* Font Details */}
|
||||
<Box>
|
||||
<Text size="xs" c="dimmed" mb={2}>
|
||||
{t('pdfTextEditor.fontAnalysis.details', 'Font Details')}:
|
||||
</Text>
|
||||
<Stack gap={2}>
|
||||
<Group gap={4}>
|
||||
<Text size="xs" c="dimmed">
|
||||
{t('pdfTextEditor.fontAnalysis.embedded', 'Embedded')}:
|
||||
</Text>
|
||||
<Code style={{ fontSize: '0.65rem', padding: '0 4px' }}>{analysis.embedded ? 'Yes' : 'No'}</Code>
|
||||
</Group>
|
||||
{analysis.subtype && (
|
||||
<Group gap={4}>
|
||||
<Text size="xs" c="dimmed">
|
||||
{t('pdfTextEditor.fontAnalysis.type', 'Type')}:
|
||||
</Text>
|
||||
<Code style={{ fontSize: '0.65rem', padding: '0 4px' }}>{analysis.subtype}</Code>
|
||||
</Group>
|
||||
)}
|
||||
{analysis.webFormat && (
|
||||
<Group gap={4}>
|
||||
<Text size="xs" c="dimmed">
|
||||
{t('pdfTextEditor.fontAnalysis.webFormat', 'Web Format')}:
|
||||
</Text>
|
||||
<Code style={{ fontSize: '0.65rem', padding: '0 4px' }}>{analysis.webFormat}</Code>
|
||||
</Group>
|
||||
)}
|
||||
</Stack>
|
||||
</Box>
|
||||
|
||||
{/* Warnings */}
|
||||
{analysis.warnings.length > 0 && (
|
||||
<Box>
|
||||
<Text size="xs" c="orange" fw={500}>
|
||||
{t('pdfTextEditor.fontAnalysis.warnings', 'Warnings')}:
|
||||
</Text>
|
||||
<List size="xs" spacing={2} withPadding>
|
||||
{analysis.warnings.map((warning, index) => (
|
||||
<List.Item key={index}>
|
||||
<Text size="xs">{warning}</Text>
|
||||
</List.Item>
|
||||
))}
|
||||
</List>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* Suggestions */}
|
||||
{analysis.suggestions.length > 0 && (
|
||||
<Box>
|
||||
<Text size="xs" c="blue" fw={500}>
|
||||
{t('pdfTextEditor.fontAnalysis.suggestions', 'Notes')}:
|
||||
</Text>
|
||||
<List size="xs" spacing={2} withPadding>
|
||||
{analysis.suggestions.map((suggestion, index) => (
|
||||
<List.Item key={index}>
|
||||
<Text size="xs">{suggestion}</Text>
|
||||
</List.Item>
|
||||
))}
|
||||
</List>
|
||||
</Box>
|
||||
)}
|
||||
</Stack>
|
||||
</Collapse>
|
||||
</Stack>
|
||||
</Paper>
|
||||
);
|
||||
};
|
||||
|
||||
const FontStatusPanel: React.FC<FontStatusPanelProps> = ({ document, pageIndex }) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const fontAnalysis: DocumentFontAnalysis = useMemo(
|
||||
() => analyzeDocumentFonts(document, pageIndex),
|
||||
[document, pageIndex]
|
||||
);
|
||||
|
||||
const { canReproducePerfectly, hasWarnings, summary, fonts } = fontAnalysis;
|
||||
|
||||
const statusIcon = useMemo(() => {
|
||||
if (canReproducePerfectly) {
|
||||
return <CheckCircleIcon sx={{ fontSize: 16 }} />;
|
||||
}
|
||||
if (hasWarnings) {
|
||||
return <WarningIcon sx={{ fontSize: 16 }} />;
|
||||
}
|
||||
return <InfoIcon sx={{ fontSize: 16 }} />;
|
||||
}, [canReproducePerfectly, hasWarnings]);
|
||||
|
||||
// Early return AFTER all hooks are declared
|
||||
if (!document || fontAnalysis.fonts.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const statusColor = canReproducePerfectly ? 'green' : hasWarnings ? 'yellow' : 'blue';
|
||||
|
||||
const pageLabel = pageIndex !== undefined
|
||||
? t('pdfTextEditor.fontAnalysis.currentPageFonts', 'Fonts on this page')
|
||||
: t('pdfTextEditor.fontAnalysis.allFonts', 'All fonts');
|
||||
|
||||
return (
|
||||
<Accordion variant="contained" defaultValue={hasWarnings ? 'fonts' : undefined}>
|
||||
<Accordion.Item value="fonts">
|
||||
<Accordion.Control>
|
||||
<Group gap="xs" wrap="wrap" style={{ flex: 1 }}>
|
||||
<Group gap="xs" wrap="nowrap">
|
||||
{statusIcon}
|
||||
<Text size="sm" fw={500}>
|
||||
{pageLabel}
|
||||
</Text>
|
||||
<Badge size="xs" color={statusColor} variant="dot">
|
||||
{fonts.length}
|
||||
</Badge>
|
||||
</Group>
|
||||
|
||||
{/* Warning badges BEFORE expansion */}
|
||||
<Group gap={4} wrap="wrap">
|
||||
{summary.systemFallback > 0 && (
|
||||
<Badge size="xs" color="yellow" variant="filled" leftSection={<WarningIcon sx={{ fontSize: 12 }} />}>
|
||||
{summary.systemFallback} {t('pdfTextEditor.fontAnalysis.fallback', 'fallback')}
|
||||
</Badge>
|
||||
)}
|
||||
{summary.missing > 0 && (
|
||||
<Badge size="xs" color="red" variant="filled" leftSection={<ErrorIcon sx={{ fontSize: 12 }} />}>
|
||||
{summary.missing} {t('pdfTextEditor.fontAnalysis.missing', 'missing')}
|
||||
</Badge>
|
||||
)}
|
||||
</Group>
|
||||
</Group>
|
||||
</Accordion.Control>
|
||||
<Accordion.Panel>
|
||||
<Stack gap="xs">
|
||||
{/* Overall Status Message */}
|
||||
<Text size="xs" c="dimmed">
|
||||
{canReproducePerfectly
|
||||
? t(
|
||||
'pdfTextEditor.fontAnalysis.perfectMessage',
|
||||
'All fonts can be reproduced perfectly.'
|
||||
)
|
||||
: hasWarnings
|
||||
? t(
|
||||
'pdfTextEditor.fontAnalysis.warningMessage',
|
||||
'Some fonts may not render correctly.'
|
||||
)
|
||||
: t(
|
||||
'pdfTextEditor.fontAnalysis.infoMessage',
|
||||
'Font reproduction information available.'
|
||||
)}
|
||||
</Text>
|
||||
|
||||
{/* Summary Statistics */}
|
||||
<Group gap={4} wrap="wrap">
|
||||
{summary.perfect > 0 && (
|
||||
<Badge size="xs" color="green" variant="light" leftSection={<CheckCircleIcon sx={{ fontSize: 12 }} />}>
|
||||
{summary.perfect} {t('pdfTextEditor.fontAnalysis.perfect', 'perfect')}
|
||||
</Badge>
|
||||
)}
|
||||
{summary.embeddedSubset > 0 && (
|
||||
<Badge size="xs" color="blue" variant="light" leftSection={<InfoIcon sx={{ fontSize: 12 }} />}>
|
||||
{summary.embeddedSubset} {t('pdfTextEditor.fontAnalysis.subset', 'subset')}
|
||||
</Badge>
|
||||
)}
|
||||
{summary.systemFallback > 0 && (
|
||||
<Badge size="xs" color="yellow" variant="light" leftSection={<WarningIcon sx={{ fontSize: 12 }} />}>
|
||||
{summary.systemFallback} {t('pdfTextEditor.fontAnalysis.fallback', 'fallback')}
|
||||
</Badge>
|
||||
)}
|
||||
{summary.missing > 0 && (
|
||||
<Badge size="xs" color="red" variant="light" leftSection={<ErrorIcon sx={{ fontSize: 12 }} />}>
|
||||
{summary.missing} {t('pdfTextEditor.fontAnalysis.missing', 'missing')}
|
||||
</Badge>
|
||||
)}
|
||||
</Group>
|
||||
|
||||
{/* Font List */}
|
||||
<Stack gap={4} mt="xs">
|
||||
{fonts.map((font, index) => (
|
||||
<FontDetailItem key={`${font.fontId}-${index}`} analysis={font} />
|
||||
))}
|
||||
</Stack>
|
||||
</Stack>
|
||||
</Accordion.Panel>
|
||||
</Accordion.Item>
|
||||
</Accordion>
|
||||
);
|
||||
};
|
||||
|
||||
export default FontStatusPanel;
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,5 +1,13 @@
|
||||
import { useMemo } from "react";
|
||||
import { type ProprietaryToolRegistry } from "@app/data/toolsTaxonomy";
|
||||
import LocalIcon from "@app/components/shared/LocalIcon";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { getSynonyms } from "@app/utils/toolSynonyms";
|
||||
import PdfTextEditor from "@app/tools/pdfTextEditor/PdfTextEditor";
|
||||
import {
|
||||
SubcategoryId,
|
||||
ToolCategoryId,
|
||||
type ProprietaryToolRegistry,
|
||||
} from "@app/data/toolsTaxonomy";
|
||||
|
||||
/**
|
||||
* Hook that provides the proprietary tool registry.
|
||||
@@ -8,6 +16,26 @@ import { type ProprietaryToolRegistry } from "@app/data/toolsTaxonomy";
|
||||
* and will be included in the main tool registry.
|
||||
*/
|
||||
export function useProprietaryToolRegistry(): ProprietaryToolRegistry {
|
||||
return useMemo(() => ({
|
||||
}), []);
|
||||
const { t } = useTranslation();
|
||||
|
||||
return useMemo<ProprietaryToolRegistry>(() => ({
|
||||
pdfTextEditor: {
|
||||
icon: <LocalIcon icon="edit-square-outline-rounded" width="1.5rem" height="1.5rem" />,
|
||||
name: t("home.pdfTextEditor.title", "PDF Text Editor"),
|
||||
component: PdfTextEditor,
|
||||
description: t(
|
||||
"home.pdfTextEditor.desc",
|
||||
"Review and edit text and images in PDFs with grouped text editing and PDF regeneration"
|
||||
),
|
||||
categoryId: ToolCategoryId.RECOMMENDED_TOOLS,
|
||||
subcategoryId: SubcategoryId.GENERAL,
|
||||
maxFiles: 1,
|
||||
endpoints: ["text-editor-pdf"],
|
||||
synonyms: getSynonyms(t, "pdfTextEditor"),
|
||||
supportsAutomate: false,
|
||||
automationSettings: null,
|
||||
versionStatus: "alpha",
|
||||
requiresPremium: true,
|
||||
},
|
||||
}), [t]);
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,421 @@
|
||||
import { PdfJsonDocument, PdfJsonFont } from '@app/tools/pdfTextEditor/pdfTextEditorTypes';
|
||||
|
||||
export type FontStatus = 'perfect' | 'embedded-subset' | 'system-fallback' | 'missing' | 'unknown';
|
||||
|
||||
export interface FontAnalysis {
|
||||
fontId: string;
|
||||
baseName: string;
|
||||
status: FontStatus;
|
||||
embedded: boolean;
|
||||
isSubset: boolean;
|
||||
isStandard14: boolean;
|
||||
hasWebFormat: boolean;
|
||||
webFormat?: string;
|
||||
subtype?: string;
|
||||
encoding?: string;
|
||||
warnings: string[];
|
||||
suggestions: string[];
|
||||
}
|
||||
|
||||
export interface DocumentFontAnalysis {
|
||||
fonts: FontAnalysis[];
|
||||
canReproducePerfectly: boolean;
|
||||
hasWarnings: boolean;
|
||||
summary: {
|
||||
perfect: number;
|
||||
embeddedSubset: number;
|
||||
systemFallback: number;
|
||||
missing: number;
|
||||
unknown: number;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Determines if a font name indicates it's a subset font.
|
||||
* Subset fonts typically have a 6-character prefix like "ABCDEE+"
|
||||
*/
|
||||
const isSubsetFont = (baseName: string | null | undefined): boolean => {
|
||||
if (!baseName) return false;
|
||||
// Check for common subset patterns: ABCDEF+FontName
|
||||
return /^[A-Z]{6}\+/.test(baseName);
|
||||
};
|
||||
|
||||
/**
|
||||
* Checks if a font is one of the standard 14 PDF fonts that are guaranteed
|
||||
* to be available on all PDF readers
|
||||
*/
|
||||
const isStandard14Font = (font: PdfJsonFont): boolean => {
|
||||
if (font.standard14Name) return true;
|
||||
|
||||
const baseName = (font.baseName || '').toLowerCase().replace(/[-_\s]/g, '');
|
||||
|
||||
const standard14Patterns = [
|
||||
'timesroman', 'timesbold', 'timesitalic', 'timesbolditalic',
|
||||
'helvetica', 'helveticabold', 'helveticaoblique', 'helveticaboldoblique',
|
||||
'courier', 'courierbold', 'courieroblique', 'courierboldoblique',
|
||||
'symbol', 'zapfdingbats'
|
||||
];
|
||||
|
||||
// Check exact matches or if the base name contains the pattern
|
||||
return standard14Patterns.some(pattern => {
|
||||
// Exact match
|
||||
if (baseName === pattern) return true;
|
||||
// Contains pattern (e.g., "ABCDEF+Helvetica" matches "helvetica")
|
||||
if (baseName.includes(pattern)) return true;
|
||||
return false;
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Checks if a font has a fallback available on the backend.
|
||||
* These fonts are embedded in the Stirling PDF backend and can be used
|
||||
* for PDF export even if not in the original PDF.
|
||||
*
|
||||
* Based on PdfJsonFallbackFontService.java
|
||||
*/
|
||||
const hasBackendFallbackFont = (font: PdfJsonFont): boolean => {
|
||||
const baseName = (font.baseName || '').toLowerCase().replace(/[-_\s]/g, '');
|
||||
|
||||
// Backend has these font families available (from PdfJsonFallbackFontService)
|
||||
const backendFonts = [
|
||||
// Liberation fonts (metric-compatible with MS core fonts)
|
||||
'arial', 'helvetica', 'arimo',
|
||||
'times', 'timesnewroman', 'tinos',
|
||||
'courier', 'couriernew', 'cousine',
|
||||
'liberation', 'liberationsans', 'liberationserif', 'liberationmono',
|
||||
// DejaVu fonts
|
||||
'dejavu', 'dejavusans', 'dejavuserif', 'dejavumono', 'dejavusansmono',
|
||||
// Noto fonts
|
||||
'noto', 'notosans'
|
||||
];
|
||||
|
||||
return backendFonts.some(pattern => {
|
||||
if (baseName === pattern) return true;
|
||||
if (baseName.includes(pattern)) return true;
|
||||
return false;
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Extracts the base font name from a subset font name
|
||||
* e.g., "ABCDEF+Arial" -> "Arial"
|
||||
*/
|
||||
const extractBaseFontName = (baseName: string | null | undefined): string | null => {
|
||||
if (!baseName) return null;
|
||||
const match = baseName.match(/^[A-Z]{6}\+(.+)$/);
|
||||
return match ? match[1] : baseName;
|
||||
};
|
||||
|
||||
/**
|
||||
* Analyzes a single font to determine if it can be reproduced perfectly
|
||||
* Takes allFonts to check if full versions of subset fonts are available
|
||||
*/
|
||||
export const analyzeFontReproduction = (font: PdfJsonFont, allFonts?: PdfJsonFont[]): FontAnalysis => {
|
||||
const fontId = font.id || font.uid || 'unknown';
|
||||
const baseName = font.baseName || 'Unknown Font';
|
||||
const isSubset = isSubsetFont(font.baseName);
|
||||
const isStandard14 = isStandard14Font(font);
|
||||
const hasBackendFallback = hasBackendFallbackFont(font);
|
||||
const embedded = font.embedded ?? false;
|
||||
|
||||
// Check available web formats (ordered by preference)
|
||||
const webFormats = [
|
||||
{ key: 'webProgram', format: font.webProgramFormat },
|
||||
{ key: 'pdfProgram', format: font.pdfProgramFormat },
|
||||
{ key: 'program', format: font.programFormat },
|
||||
];
|
||||
|
||||
const availableWebFormat = webFormats.find(f => f.format);
|
||||
const hasWebFormat = !!availableWebFormat;
|
||||
const webFormat = availableWebFormat?.format || undefined;
|
||||
|
||||
const warnings: string[] = [];
|
||||
const suggestions: string[] = [];
|
||||
let status: FontStatus = 'unknown';
|
||||
|
||||
// Check if we have the full font when this is a subset
|
||||
let hasFullFontVersion = false;
|
||||
if (isSubset && allFonts) {
|
||||
const baseFont = extractBaseFontName(font.baseName);
|
||||
if (baseFont) {
|
||||
// Look for a non-subset version of this font with a web format
|
||||
hasFullFontVersion = allFonts.some(f => {
|
||||
const otherBaseName = extractBaseFontName(f.baseName);
|
||||
const isNotSubset = !isSubsetFont(f.baseName);
|
||||
const hasFormat = !!(f.webProgramFormat || f.pdfProgramFormat || f.programFormat);
|
||||
const sameBase = otherBaseName?.toLowerCase() === baseFont.toLowerCase();
|
||||
return sameBase && isNotSubset && hasFormat && (f.embedded ?? false);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Analyze font status - focusing on PDF export quality
|
||||
if (isStandard14) {
|
||||
// Standard 14 fonts are always available in PDF readers - perfect for export!
|
||||
status = 'perfect';
|
||||
suggestions.push('Standard PDF font (Times, Helvetica, or Courier). Always available in PDF readers.');
|
||||
suggestions.push('Exported PDFs will render consistently across all PDF readers.');
|
||||
} else if (embedded && !isSubset) {
|
||||
// Perfect: Fully embedded with complete character set
|
||||
status = 'perfect';
|
||||
suggestions.push('Font is fully embedded. Exported PDFs will reproduce text perfectly, even with edits.');
|
||||
} else if (embedded && isSubset && (hasFullFontVersion || hasBackendFallback)) {
|
||||
// Subset but we have the full font or backend fallback - perfect!
|
||||
status = 'perfect';
|
||||
if (hasFullFontVersion) {
|
||||
suggestions.push('Full font version is also available in the document. Exported PDFs can reproduce all characters.');
|
||||
} else if (hasBackendFallback) {
|
||||
suggestions.push('Backend has the full font available. Exported PDFs can reproduce all characters, including new text.');
|
||||
}
|
||||
} else if (embedded && isSubset) {
|
||||
// Good, but subset: May have missing characters if user adds new text
|
||||
status = 'embedded-subset';
|
||||
warnings.push('This is a subset font - only specific characters are embedded in the PDF.');
|
||||
warnings.push('Exported PDFs may have missing characters if you add new text with this font.');
|
||||
suggestions.push('Existing text will export correctly. New characters may render as boxes (☐) or fallback glyphs.');
|
||||
} else if (!embedded && hasBackendFallback) {
|
||||
// Not embedded, but backend has it - perfect for export!
|
||||
status = 'perfect';
|
||||
suggestions.push('Backend has this font available. Exported PDFs will use the backend fallback font.');
|
||||
suggestions.push('Text will export correctly with consistent appearance.');
|
||||
} else if (!embedded) {
|
||||
// Not embedded - must rely on system fonts (risky for export)
|
||||
status = 'missing';
|
||||
warnings.push('Font is not embedded in the PDF.');
|
||||
warnings.push('Exported PDFs will substitute with a fallback font, which may look very different.');
|
||||
suggestions.push('Consider re-embedding fonts or accepting that the exported PDF will use fallback fonts.');
|
||||
} else if (embedded && !hasWebFormat) {
|
||||
// Embedded but no web format available (still okay for export)
|
||||
status = 'perfect';
|
||||
suggestions.push('Font is embedded in the PDF. Exported PDFs will reproduce correctly.');
|
||||
suggestions.push('Web preview may use a fallback font, but the final PDF export will be accurate.');
|
||||
}
|
||||
|
||||
// Additional warnings based on font properties
|
||||
if (font.subtype === 'Type0' && font.cidSystemInfo) {
|
||||
const registry = font.cidSystemInfo.registry || '';
|
||||
const ordering = font.cidSystemInfo.ordering || '';
|
||||
if (registry.includes('Adobe') && (ordering.includes('Identity') || ordering.includes('UCS'))) {
|
||||
// CID fonts with Identity encoding are common for Asian languages
|
||||
if (!embedded || !hasWebFormat) {
|
||||
warnings.push('This CID font may contain Asian or Unicode characters.');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (font.encoding && !font.encoding.includes('WinAnsiEncoding') && !font.encoding.includes('MacRomanEncoding')) {
|
||||
// Custom encodings may cause issues
|
||||
if (font.encoding !== 'Identity-H' && font.encoding !== 'Identity-V') {
|
||||
warnings.push(`Custom encoding detected: ${font.encoding}`);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
fontId,
|
||||
baseName,
|
||||
status,
|
||||
embedded,
|
||||
isSubset,
|
||||
isStandard14,
|
||||
hasWebFormat,
|
||||
webFormat,
|
||||
subtype: font.subtype || undefined,
|
||||
encoding: font.encoding || undefined,
|
||||
warnings,
|
||||
suggestions,
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Gets fonts used on a specific page
|
||||
*/
|
||||
export const getFontsForPage = (
|
||||
document: PdfJsonDocument | null,
|
||||
pageIndex: number
|
||||
): PdfJsonFont[] => {
|
||||
if (!document?.fonts || !document?.pages || pageIndex < 0 || pageIndex >= document.pages.length) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const page = document.pages[pageIndex];
|
||||
if (!page?.textElements) {
|
||||
return [];
|
||||
}
|
||||
|
||||
// Get unique font IDs used on this page
|
||||
const fontIdsOnPage = new Set<string>();
|
||||
page.textElements.forEach(element => {
|
||||
if (element?.fontId) {
|
||||
fontIdsOnPage.add(element.fontId);
|
||||
}
|
||||
});
|
||||
|
||||
// Filter fonts to only those used on this page
|
||||
const allFonts = document.fonts.filter((font): font is PdfJsonFont => font !== null && font !== undefined);
|
||||
|
||||
const fontsOnPage = allFonts.filter(font => {
|
||||
// Match by ID
|
||||
if (font.id && fontIdsOnPage.has(font.id)) {
|
||||
return true;
|
||||
}
|
||||
// Match by UID
|
||||
if (font.uid && fontIdsOnPage.has(font.uid)) {
|
||||
return true;
|
||||
}
|
||||
// Match by page-specific ID (pageNumber:id format)
|
||||
if (font.pageNumber === pageIndex + 1 && font.id) {
|
||||
const pageSpecificId = `${font.pageNumber}:${font.id}`;
|
||||
if (fontIdsOnPage.has(pageSpecificId) || fontIdsOnPage.has(font.id)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
});
|
||||
|
||||
// Deduplicate by base font name to avoid showing the same font multiple times
|
||||
const uniqueFonts = new Map<string, PdfJsonFont>();
|
||||
fontsOnPage.forEach(font => {
|
||||
const baseName = extractBaseFontName(font.baseName) || font.baseName || font.id || 'unknown';
|
||||
const key = baseName.toLowerCase();
|
||||
|
||||
// Keep the first occurrence, or prefer non-subset over subset
|
||||
const existing = uniqueFonts.get(key);
|
||||
if (!existing) {
|
||||
uniqueFonts.set(key, font);
|
||||
} else {
|
||||
// Prefer non-subset fonts over subset fonts
|
||||
const existingIsSubset = isSubsetFont(existing.baseName);
|
||||
const currentIsSubset = isSubsetFont(font.baseName);
|
||||
if (existingIsSubset && !currentIsSubset) {
|
||||
uniqueFonts.set(key, font);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return Array.from(uniqueFonts.values());
|
||||
};
|
||||
|
||||
/**
|
||||
* Analyzes all fonts in a PDF document (or just fonts for a specific page)
|
||||
*/
|
||||
export const analyzeDocumentFonts = (
|
||||
document: PdfJsonDocument | null,
|
||||
pageIndex?: number
|
||||
): DocumentFontAnalysis => {
|
||||
if (!document?.fonts || document.fonts.length === 0) {
|
||||
return {
|
||||
fonts: [],
|
||||
canReproducePerfectly: true,
|
||||
hasWarnings: false,
|
||||
summary: {
|
||||
perfect: 0,
|
||||
embeddedSubset: 0,
|
||||
systemFallback: 0,
|
||||
missing: 0,
|
||||
unknown: 0,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const allFonts = document.fonts.filter((font): font is PdfJsonFont => font !== null && font !== undefined);
|
||||
|
||||
// Filter to page-specific fonts if pageIndex is provided
|
||||
const fontsToAnalyze = pageIndex !== undefined
|
||||
? getFontsForPage(document, pageIndex)
|
||||
: allFonts;
|
||||
|
||||
if (fontsToAnalyze.length === 0) {
|
||||
return {
|
||||
fonts: [],
|
||||
canReproducePerfectly: true,
|
||||
hasWarnings: false,
|
||||
summary: {
|
||||
perfect: 0,
|
||||
embeddedSubset: 0,
|
||||
systemFallback: 0,
|
||||
missing: 0,
|
||||
unknown: 0,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const fontAnalyses = fontsToAnalyze.map(font => analyzeFontReproduction(font, allFonts));
|
||||
|
||||
// Calculate summary
|
||||
const summary = {
|
||||
perfect: fontAnalyses.filter(f => f.status === 'perfect').length,
|
||||
embeddedSubset: fontAnalyses.filter(f => f.status === 'embedded-subset').length,
|
||||
systemFallback: fontAnalyses.filter(f => f.status === 'system-fallback').length,
|
||||
missing: fontAnalyses.filter(f => f.status === 'missing').length,
|
||||
unknown: fontAnalyses.filter(f => f.status === 'unknown').length,
|
||||
};
|
||||
|
||||
// Can reproduce perfectly ONLY if all fonts are truly perfect (not subsets)
|
||||
const canReproducePerfectly = fontAnalyses.every(f => f.status === 'perfect');
|
||||
|
||||
// Has warnings if any font has issues (including subsets)
|
||||
const hasWarnings = fontAnalyses.some(
|
||||
f => f.warnings.length > 0 || f.status === 'missing' || f.status === 'system-fallback' || f.status === 'embedded-subset'
|
||||
);
|
||||
|
||||
return {
|
||||
fonts: fontAnalyses,
|
||||
canReproducePerfectly,
|
||||
hasWarnings,
|
||||
summary,
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Gets a human-readable description of the font status
|
||||
*/
|
||||
export const getFontStatusDescription = (status: FontStatus): string => {
|
||||
switch (status) {
|
||||
case 'perfect':
|
||||
return 'Fully embedded - perfect reproduction';
|
||||
case 'embedded-subset':
|
||||
return 'Embedded (subset) - existing text will render correctly';
|
||||
case 'system-fallback':
|
||||
return 'Using system font - appearance may differ';
|
||||
case 'missing':
|
||||
return 'Not embedded - will use fallback font';
|
||||
case 'unknown':
|
||||
return 'Unknown status';
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Gets a color indicator for the font status
|
||||
*/
|
||||
export const getFontStatusColor = (status: FontStatus): string => {
|
||||
switch (status) {
|
||||
case 'perfect':
|
||||
return 'green';
|
||||
case 'embedded-subset':
|
||||
return 'blue';
|
||||
case 'system-fallback':
|
||||
return 'yellow';
|
||||
case 'missing':
|
||||
return 'red';
|
||||
case 'unknown':
|
||||
return 'gray';
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Gets an icon indicator for the font status
|
||||
*/
|
||||
export const getFontStatusIcon = (status: FontStatus): string => {
|
||||
switch (status) {
|
||||
case 'perfect':
|
||||
return '✓';
|
||||
case 'embedded-subset':
|
||||
return '⚠';
|
||||
case 'system-fallback':
|
||||
return '⚠';
|
||||
case 'missing':
|
||||
return '✗';
|
||||
case 'unknown':
|
||||
return '?';
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,228 @@
|
||||
export interface PdfJsonFontCidSystemInfo {
|
||||
registry?: string | null;
|
||||
ordering?: string | null;
|
||||
supplement?: number | null;
|
||||
}
|
||||
|
||||
export interface PdfJsonTextColor {
|
||||
colorSpace?: string | null;
|
||||
components?: number[] | null;
|
||||
}
|
||||
|
||||
export interface PdfJsonCosValue {
|
||||
type?: string | null;
|
||||
value?: unknown;
|
||||
items?: PdfJsonCosValue[] | null;
|
||||
entries?: Record<string, PdfJsonCosValue | null> | null;
|
||||
stream?: PdfJsonStream | null;
|
||||
}
|
||||
|
||||
export interface PdfJsonFont {
|
||||
id?: string;
|
||||
pageNumber?: number | null;
|
||||
uid?: string | null;
|
||||
baseName?: string | null;
|
||||
subtype?: string | null;
|
||||
encoding?: string | null;
|
||||
cidSystemInfo?: PdfJsonFontCidSystemInfo | null;
|
||||
embedded?: boolean | null;
|
||||
program?: string | null;
|
||||
programFormat?: string | null;
|
||||
webProgram?: string | null;
|
||||
webProgramFormat?: string | null;
|
||||
pdfProgram?: string | null;
|
||||
pdfProgramFormat?: string | null;
|
||||
toUnicode?: string | null;
|
||||
standard14Name?: string | null;
|
||||
fontDescriptorFlags?: number | null;
|
||||
ascent?: number | null;
|
||||
descent?: number | null;
|
||||
capHeight?: number | null;
|
||||
xHeight?: number | null;
|
||||
italicAngle?: number | null;
|
||||
unitsPerEm?: number | null;
|
||||
cosDictionary?: PdfJsonCosValue | null;
|
||||
}
|
||||
|
||||
export interface PdfJsonTextElement {
|
||||
text?: string | null;
|
||||
fontId?: string | null;
|
||||
fontSize?: number | null;
|
||||
fontMatrixSize?: number | null;
|
||||
fontSizeInPt?: number | null;
|
||||
characterSpacing?: number | null;
|
||||
wordSpacing?: number | null;
|
||||
spaceWidth?: number | null;
|
||||
zOrder?: number | null;
|
||||
horizontalScaling?: number | null;
|
||||
leading?: number | null;
|
||||
rise?: number | null;
|
||||
renderingMode?: number | null;
|
||||
x?: number | null;
|
||||
y?: number | null;
|
||||
width?: number | null;
|
||||
height?: number | null;
|
||||
textMatrix?: number[] | null;
|
||||
fillColor?: PdfJsonTextColor | null;
|
||||
strokeColor?: PdfJsonTextColor | null;
|
||||
charCodes?: number[] | null;
|
||||
fallbackUsed?: boolean | null;
|
||||
}
|
||||
|
||||
export interface PdfJsonImageElement {
|
||||
id?: string | null;
|
||||
objectName?: string | null;
|
||||
inlineImage?: boolean | null;
|
||||
nativeWidth?: number | null;
|
||||
nativeHeight?: number | null;
|
||||
x?: number | null;
|
||||
y?: number | null;
|
||||
width?: number | null;
|
||||
height?: number | null;
|
||||
left?: number | null;
|
||||
right?: number | null;
|
||||
top?: number | null;
|
||||
bottom?: number | null;
|
||||
transform?: number[] | null;
|
||||
zOrder?: number | null;
|
||||
imageData?: string | null;
|
||||
imageFormat?: string | null;
|
||||
}
|
||||
|
||||
export interface PdfJsonStream {
|
||||
dictionary?: Record<string, unknown> | null;
|
||||
rawData?: string | null;
|
||||
}
|
||||
|
||||
export interface PdfJsonPage {
|
||||
pageNumber?: number | null;
|
||||
width?: number | null;
|
||||
height?: number | null;
|
||||
rotation?: number | null;
|
||||
mediaBox?: number[] | null;
|
||||
cropBox?: number[] | null;
|
||||
textElements?: PdfJsonTextElement[] | null;
|
||||
imageElements?: PdfJsonImageElement[] | null;
|
||||
resources?: unknown;
|
||||
contentStreams?: PdfJsonStream[] | null;
|
||||
}
|
||||
|
||||
export interface PdfJsonMetadata {
|
||||
title?: string | null;
|
||||
author?: string | null;
|
||||
subject?: string | null;
|
||||
keywords?: string | null;
|
||||
creator?: string | null;
|
||||
producer?: string | null;
|
||||
creationDate?: string | null;
|
||||
modificationDate?: string | null;
|
||||
trapped?: string | null;
|
||||
numberOfPages?: number | null;
|
||||
}
|
||||
|
||||
export interface PdfJsonDocument {
|
||||
metadata?: PdfJsonMetadata | null;
|
||||
xmpMetadata?: string | null;
|
||||
fonts?: PdfJsonFont[] | null;
|
||||
pages?: PdfJsonPage[] | null;
|
||||
lazyImages?: boolean | null;
|
||||
}
|
||||
|
||||
export interface PdfJsonPageDimension {
|
||||
pageNumber?: number | null;
|
||||
width?: number | null;
|
||||
height?: number | null;
|
||||
rotation?: number | null;
|
||||
}
|
||||
|
||||
export interface PdfJsonDocumentMetadata {
|
||||
metadata?: PdfJsonMetadata | null;
|
||||
xmpMetadata?: string | null;
|
||||
fonts?: PdfJsonFont[] | null;
|
||||
pageDimensions?: PdfJsonPageDimension[] | null;
|
||||
formFields?: unknown[] | null;
|
||||
lazyImages?: boolean | null;
|
||||
}
|
||||
|
||||
export interface BoundingBox {
|
||||
left: number;
|
||||
right: number;
|
||||
top: number;
|
||||
bottom: number;
|
||||
}
|
||||
|
||||
export interface TextGroup {
|
||||
id: string;
|
||||
pageIndex: number;
|
||||
fontId?: string | null;
|
||||
fontSize?: number | null;
|
||||
fontMatrixSize?: number | null;
|
||||
lineSpacing?: number | null;
|
||||
lineElementCounts?: number[] | null;
|
||||
color?: string | null;
|
||||
fontWeight?: number | 'normal' | 'bold' | null;
|
||||
rotation?: number | null;
|
||||
anchor?: { x: number; y: number } | null;
|
||||
baselineLength?: number | null;
|
||||
baseline?: number | null;
|
||||
elements: PdfJsonTextElement[];
|
||||
originalElements: PdfJsonTextElement[];
|
||||
text: string;
|
||||
originalText: string;
|
||||
bounds: BoundingBox;
|
||||
childLineGroups?: TextGroup[] | null;
|
||||
}
|
||||
|
||||
export const DEFAULT_PAGE_WIDTH = 612;
|
||||
export const DEFAULT_PAGE_HEIGHT = 792;
|
||||
|
||||
export interface ConversionProgress {
|
||||
percent: number;
|
||||
stage: string;
|
||||
message: string;
|
||||
current?: number;
|
||||
total?: number;
|
||||
}
|
||||
|
||||
export interface PdfTextEditorViewData {
|
||||
document: PdfJsonDocument | null;
|
||||
groupsByPage: TextGroup[][];
|
||||
imagesByPage: PdfJsonImageElement[][];
|
||||
pagePreviews: Map<number, string>;
|
||||
selectedPage: number;
|
||||
dirtyPages: boolean[];
|
||||
hasDocument: boolean;
|
||||
hasVectorPreview: boolean;
|
||||
fileName: string;
|
||||
errorMessage: string | null;
|
||||
isGeneratingPdf: boolean;
|
||||
isConverting: boolean;
|
||||
conversionProgress: ConversionProgress | null;
|
||||
hasChanges: boolean;
|
||||
forceSingleTextElement: boolean;
|
||||
groupingMode: 'auto' | 'paragraph' | 'singleLine';
|
||||
requestPagePreview: (pageIndex: number, scale: number) => void;
|
||||
onSelectPage: (pageIndex: number) => void;
|
||||
onGroupEdit: (pageIndex: number, groupId: string, value: string) => void;
|
||||
onGroupDelete: (pageIndex: number, groupId: string) => void;
|
||||
onImageTransform: (
|
||||
pageIndex: number,
|
||||
imageId: string,
|
||||
next: {
|
||||
left: number;
|
||||
bottom: number;
|
||||
width: number;
|
||||
height: number;
|
||||
transform: number[];
|
||||
},
|
||||
) => void;
|
||||
onImageReset: (pageIndex: number, imageId: string) => void;
|
||||
onReset: () => void;
|
||||
onDownloadJson: () => void;
|
||||
onGeneratePdf: () => void;
|
||||
onGeneratePdfForNavigation: () => Promise<void>;
|
||||
onForceSingleTextElementChange: (value: boolean) => void;
|
||||
onGroupingModeChange: (value: 'auto' | 'paragraph' | 'singleLine') => void;
|
||||
onMergeGroups: (pageIndex: number, groupIds: string[]) => boolean;
|
||||
onUngroupGroup: (pageIndex: number, groupId: string) => boolean;
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -5,6 +5,7 @@
|
||||
*/
|
||||
|
||||
export const PROPRIETARY_REGULAR_TOOL_IDS = [
|
||||
'pdfTextEditor',
|
||||
] as const;
|
||||
|
||||
export const PROPRIETARY_SUPER_TOOL_IDS = [
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
/**
|
||||
* Proprietary URL mappings.
|
||||
* This file overrides src/core/utils/urlMapping.ts
|
||||
* to add proprietary-specific URL mappings.
|
||||
*/
|
||||
|
||||
import { ToolId } from '@app/types/toolId';
|
||||
import { URL_TO_TOOL_MAP as CORE_URL_TO_TOOL_MAP } from '@core/utils/urlMapping';
|
||||
|
||||
// Proprietary URL mappings
|
||||
const PROPRIETARY_URL_MAPPINGS: Record<string, ToolId> = {
|
||||
'/pdf-text-editor': 'pdfTextEditor',
|
||||
};
|
||||
|
||||
// Merge core and proprietary mappings
|
||||
export const URL_TO_TOOL_MAP: Record<string, ToolId> = {
|
||||
...CORE_URL_TO_TOOL_MAP,
|
||||
...PROPRIETARY_URL_MAPPINGS,
|
||||
};
|
||||
Reference in New Issue
Block a user