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:
Anthony Stirling
2025-11-24 14:15:02 +00:00
committed by GitHub
co-authored by EthanHealy01
parent d42065e338
commit b0397da19e
253 changed files with 26069 additions and 111 deletions
@@ -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',