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
@@ -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]);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user