## Summary
- introduce a shared line art conversion interface and proprietary
ImageMagick-backed implementation
- have the compress controller optionally autowire the enterprise
service before running per-image line art processing
- remove ImageMagick command details from core by delegating conversions
through the proprietary service

## Testing
- not run (not requested)


------
[Codex
Task](https://chatgpt.com/codex/tasks/task_b_6928aecceaf083289a9269b1ca99307e)

---------

Co-authored-by: James Brunton <[email protected]>
This commit is contained in:
Anthony Stirling
2025-12-15 11:14:10 +00:00
committed by GitHub
co-authored by James Brunton
parent 33188815da
commit 5f72c05623
24 changed files with 445 additions and 75 deletions
@@ -3729,6 +3729,16 @@ filesize = "File Size"
[compress.grayscale]
label = "Apply Grayscale for Compression"
[compress.lineArt]
label = "Convert images to line art"
description = "Uses ImageMagick to reduce pages to high-contrast black and white for maximum size reduction."
unavailable = "ImageMagick is not installed or enabled on this server"
detailLevel = "Detail level"
edgeEmphasis = "Edge emphasis"
edgeLow = "Gentle"
edgeMedium = "Balanced"
edgeHigh = "Strong"
[compress.tooltip.header]
title = "Compress Settings Overview"
@@ -3746,6 +3756,10 @@ bullet2 = "Higher values reduce file size"
title = "Grayscale"
text = "Select this option to convert all images to black and white, which can significantly reduce file size especially for scanned PDFs or image-heavy documents."
[compress.tooltip.lineArt]
title = "Line Art"
text = "Convert pages to high-contrast black and white using ImageMagick. Use detail level to control how much content becomes black, and edge emphasis to control how aggressively edges are detected."
[compress.error]
failed = "An error occurred while compressing the PDF."
+77 -69
View File
@@ -1,74 +1,82 @@
{
"$schema": "../node_modules/@tauri-apps/cli/config.schema.json",
"productName": "Stirling-PDF",
"version": "2.0.0",
"identifier": "stirling.pdf.dev",
"build": {
"frontendDist": "../dist",
"devUrl": "http://localhost:5173",
"beforeDevCommand": "npm run dev -- --mode desktop",
"beforeBuildCommand": "npm run build -- --mode desktop"
},
"app": {
"windows": [
{
"title": "Stirling-PDF",
"width": 1280,
"height": 800,
"resizable": true,
"fullscreen": false
}
]
},
"bundle": {
"active": true,
"targets": ["deb", "rpm", "dmg", "app", "msi"],
"icon": [
"icons/icon.png",
"icons/icon.icns",
"icons/icon.ico",
"icons/16x16.png",
"icons/32x32.png",
"icons/64x64.png",
"icons/128x128.png",
"icons/192x192.png"
],
"resources": [
"libs/*.jar",
"runtime/jre/**/*"
],
"fileAssociations": [
{
"ext": ["pdf"],
"name": "PDF Document",
"description": "Open PDF files with Stirling-PDF",
"role": "Editor",
"mimeType": "application/pdf"
}
],
"linux": {
"deb": {
"desktopTemplate": "stirling-pdf.desktop"
}
"$schema": "../node_modules/@tauri-apps/cli/config.schema.json",
"productName": "Stirling-PDF",
"version": "2.1.3",
"identifier": "stirling.pdf.dev",
"build": {
"frontendDist": "../dist",
"devUrl": "http://localhost:5173",
"beforeDevCommand": "npm run dev -- --mode desktop",
"beforeBuildCommand": "npm run build -- --mode desktop"
},
"windows": {
"certificateThumbprint": null,
"digestAlgorithm": "sha256",
"timestampUrl": "http://timestamp.digicert.com"
"app": {
"windows": [
{
"title": "Stirling-PDF",
"width": 1280,
"height": 800,
"resizable": true,
"fullscreen": false
}
]
},
"macOS": {
"minimumSystemVersion": "10.15",
"signingIdentity": null,
"entitlements": null,
"providerShortName": null
"bundle": {
"active": true,
"targets": [
"deb",
"rpm",
"dmg",
"app",
"msi"
],
"icon": [
"icons/icon.png",
"icons/icon.icns",
"icons/icon.ico",
"icons/16x16.png",
"icons/32x32.png",
"icons/64x64.png",
"icons/128x128.png",
"icons/192x192.png"
],
"resources": [
"libs/*.jar",
"runtime/jre/**/*"
],
"fileAssociations": [
{
"ext": [
"pdf"
],
"name": "PDF Document",
"description": "Open PDF files with Stirling-PDF",
"role": "Editor",
"mimeType": "application/pdf"
}
],
"linux": {
"deb": {
"desktopTemplate": "stirling-pdf.desktop"
}
},
"windows": {
"certificateThumbprint": null,
"digestAlgorithm": "sha256",
"timestampUrl": "http://timestamp.digicert.com"
},
"macOS": {
"minimumSystemVersion": "10.15",
"signingIdentity": null,
"entitlements": null,
"providerShortName": null
}
},
"plugins": {
"shell": {
"open": true
},
"fs": {
"requireLiteralLeadingDot": false
}
}
},
"plugins": {
"shell": {
"open": true
},
"fs": {
"requireLiteralLeadingDot": false
}
}
}
@@ -1,8 +1,9 @@
import { useState } from "react";
import { Stack, Text, NumberInput, Select, Divider, Checkbox } from "@mantine/core";
import { useState, useEffect } from "react";
import { Stack, Text, NumberInput, Select, Divider, Checkbox, Slider, SegmentedControl } from "@mantine/core";
import { useTranslation } from "react-i18next";
import { CompressParameters } from "@app/hooks/tools/compress/useCompressParameters";
import ButtonSelector from "@app/components/shared/ButtonSelector";
import apiClient from "@app/services/apiClient";
interface CompressSettingsProps {
parameters: CompressParameters;
@@ -13,6 +14,20 @@ interface CompressSettingsProps {
const CompressSettings = ({ parameters, onParameterChange, disabled = false }: CompressSettingsProps) => {
const { t } = useTranslation();
const [isSliding, setIsSliding] = useState(false);
const [imageMagickAvailable, setImageMagickAvailable] = useState<boolean | null>(null);
useEffect(() => {
const checkImageMagick = async () => {
try {
const response = await apiClient.get<boolean>('/api/v1/config/group-enabled?group=ImageMagick');
setImageMagickAvailable(response.data);
} catch (error) {
console.error('Failed to check ImageMagick availability:', error);
setImageMagickAvailable(true); // Optimistic fallback
}
};
checkImageMagick();
}, []);
return (
<Stack gap="md">
@@ -129,6 +144,62 @@ const CompressSettings = ({ parameters, onParameterChange, disabled = false }: C
disabled={disabled}
label={t("compress.grayscale.label", "Apply Grayscale for compression")}
/>
<Checkbox
checked={parameters.lineArt}
onChange={(event) => onParameterChange('lineArt', event.currentTarget.checked)}
disabled={disabled || imageMagickAvailable === false}
label={t("compress.lineArt.label", "Convert images to line art (bilevel)")}
description={
imageMagickAvailable === false
? t("compress.lineArt.unavailable", "ImageMagick is not installed or enabled on this server")
: t("compress.lineArt.description", "Uses ImageMagick to reduce pages to high-contrast black and white for maximum size reduction.")
}
/>
{parameters.lineArt && (
<Stack gap="xs" style={{ opacity: (disabled || imageMagickAvailable === false) ? 0.6 : 1 }}>
<Text size="sm" fw={600}>{t('compress.lineArt.detailLevel', 'Detail level')}</Text>
<Slider
min={1}
max={5}
step={1}
value={(() => {
// Map threshold to slider position
const thresholdMap = [20, 35, 50, 65, 80];
const closest = thresholdMap.reduce((prev, curr, idx) =>
Math.abs(curr - parameters.lineArtThreshold) < Math.abs(thresholdMap[prev] - parameters.lineArtThreshold)
? idx : prev, 0);
return closest + 1;
})()}
onChange={(value) => {
// Map slider position to threshold: 1=20%, 2=35%, 3=50%, 4=65%, 5=80%
const thresholdMap = [20, 35, 50, 65, 80];
onParameterChange('lineArtThreshold', thresholdMap[value - 1]);
}}
disabled={disabled || imageMagickAvailable === false}
label={null}
marks={[
{ value: 1 },
{ value: 2 },
{ value: 3 },
{ value: 4 },
{ value: 5 },
]}
/>
<Text size="sm" fw={600}>{t('compress.lineArt.edgeEmphasis', 'Edge emphasis')}</Text>
<SegmentedControl
fullWidth
disabled={disabled || imageMagickAvailable === false}
data={[
{ value: '1', label: t('compress.lineArt.edgeLow', 'Gentle') },
{ value: '2', label: t('compress.lineArt.edgeMedium', 'Balanced') },
{ value: '3', label: t('compress.lineArt.edgeHigh', 'Strong') },
]}
value={parameters.lineArtEdgeLevel.toString()}
onChange={(value) => onParameterChange('lineArtEdgeLevel', parseInt(value) as 1 | 2 | 3)}
/>
</Stack>
)}
</Stack>
</Stack>
);
@@ -24,6 +24,13 @@ export const useCompressTips = (): TooltipContent => {
{
title: t("compress.tooltip.grayscale.title", "Grayscale"),
description: t("compress.tooltip.grayscale.text", "Select this option to convert all images to black and white, which can significantly reduce file size especially for scanned PDFs or image-heavy documents.")
},
{
title: t("compress.tooltip.lineArt.title", "Line Art"),
description: t(
"compress.tooltip.lineArt.text",
"Convert pages to high-contrast black and white using ImageMagick. Use line thickness to control the threshold percentage and detection strength to choose how aggressively edges are outlined."
)
}
]
};
@@ -19,6 +19,11 @@ export const buildCompressFormData = (parameters: CompressParameters, file: File
}
formData.append("grayscale", parameters.grayscale.toString());
formData.append("lineArt", parameters.lineArt.toString());
if (parameters.lineArt) {
formData.append("lineArtThreshold", parameters.lineArtThreshold.toString());
formData.append("lineArtEdgeLevel", parameters.lineArtEdgeLevel.toString());
}
return formData;
};
@@ -4,6 +4,9 @@ import { useBaseParameters, BaseParametersHook } from '@app/hooks/tools/shared/u
export interface CompressParameters extends BaseParameters {
compressionLevel: number;
grayscale: boolean;
lineArt: boolean;
lineArtThreshold: number;
lineArtEdgeLevel: 1 | 2 | 3;
expectedSize: string;
compressionMethod: 'quality' | 'filesize';
fileSizeValue: string;
@@ -13,6 +16,9 @@ export interface CompressParameters extends BaseParameters {
export const defaultParameters: CompressParameters = {
compressionLevel: 5,
grayscale: false,
lineArt: false,
lineArtThreshold: 50,
lineArtEdgeLevel: 3,
expectedSize: '',
compressionMethod: 'quality',
fileSizeValue: '',
@@ -38,7 +38,7 @@ const FREE_LICENSE_INFO: LicenseInfo = {
const BASE_NO_LOGIN_CONFIG: AppConfig = {
enableAnalytics: true,
appVersion: '2.0.0',
appVersion: '2.1.3',
serverCertificateEnabled: false,
enableAlphaFunctionality: false,
serverPort: 8080,
@@ -48,7 +48,7 @@ const FREE_LICENSE_INFO: LicenseInfo = {
const BASE_NO_LOGIN_CONFIG: AppConfig = {
enableAnalytics: true,
appVersion: '2.0.0',
appVersion: '2.1.3',
serverCertificateEnabled: false,
enableAlphaFunctionality: false,
enableDesktopInstallSlide: true,