Compare commits
70 Commits
fix/contac
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| bff150b1a7 | |||
| 8ffb1e5f6a | |||
| 42ff5b6226 | |||
| 539c503d0d | |||
| 4c473a2ffd | |||
| 4f64a94a15 | |||
| 5a82243965 | |||
| ebb14ee302 | |||
| 80f5e46d88 | |||
| b826b5b741 | |||
| 41228aca53 | |||
| cd3b7c1e3b | |||
| ecd525d782 | |||
| 52439cc5aa | |||
| 61bf8517d8 | |||
| 85d9e3a930 | |||
| 2f7d6befe4 | |||
| a6ba484faa | |||
| d7178a9100 | |||
| 1e39dc8567 | |||
| 3c2aa8b71e | |||
| 6e3befdfd9 | |||
| 5377b3c81a | |||
| 941625a4e0 | |||
| 9b251101d4 | |||
| ff0d880bde | |||
| b78a848ec2 | |||
| e72879b222 | |||
| 5a2b0c543e | |||
| 1203c20166 | |||
| 9944d3313b | |||
| d1cbfc41c8 | |||
| 09d435a1bc | |||
| 8f34ac2a6d | |||
| e5ecf16a66 | |||
| 12eadba0ee | |||
| 0ad975f049 | |||
| b53ea82be8 | |||
| 3d7a061858 | |||
| 44ad62de1c | |||
| 092325baa9 | |||
| a0910dd932 | |||
| db251687d1 | |||
| eaf17315ff | |||
| 6bab5c9173 | |||
| cf371e48ef | |||
| ec0c71a6a6 | |||
| 1b7fecff4a | |||
| b7f2d932a9 | |||
| aa3b0b05b8 | |||
| 4c6e8cb28b | |||
| 175cb10e01 | |||
| 5e8a175a53 | |||
| 9f64304539 | |||
| 6367efdf6f | |||
| 22ae008f29 | |||
| 118319ceab | |||
| 5d94337df1 | |||
| 3798d37fcd | |||
| efe3cbc58e | |||
| f798afe917 | |||
| 1623e04fce | |||
| b77eb2087e | |||
| 7e7acff670 | |||
| 069400dd86 | |||
| 91f5cb8de3 | |||
| 702313e42c | |||
| adafe8ac02 | |||
| 3599880f3a | |||
| 79f0231e26 |
@@ -256,6 +256,10 @@ jobs:
|
||||
# Analytics
|
||||
UMAMI_WEBSITE_ID: ${{ secrets.UMAMI_WEBSITE_ID || vars.UMAMI_WEBSITE_ID }}
|
||||
UMAMI_API_ENDPOINT: ${{ secrets.UMAMI_API_ENDPOINT || vars.UMAMI_API_ENDPOINT || 'https://analytics.infra.mintel.me' }}
|
||||
|
||||
# Notifications
|
||||
GOTIFY_URL: ${{ secrets.GOTIFY_URL }}
|
||||
GOTIFY_TOKEN: ${{ secrets.GOTIFY_TOKEN }}
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
@@ -309,6 +313,10 @@ jobs:
|
||||
echo "UMAMI_WEBSITE_ID=$UMAMI_WEBSITE_ID"
|
||||
echo "UMAMI_API_ENDPOINT=$UMAMI_API_ENDPOINT"
|
||||
echo ""
|
||||
echo "# Notifications"
|
||||
echo "GOTIFY_URL=$GOTIFY_URL"
|
||||
echo "GOTIFY_TOKEN=$GOTIFY_TOKEN"
|
||||
echo ""
|
||||
echo "TARGET=$TARGET"
|
||||
echo "SENTRY_ENVIRONMENT=$TARGET"
|
||||
echo "PROJECT_NAME=$PROJECT_NAME"
|
||||
|
||||
@@ -3,7 +3,7 @@ import Image from 'next/image';
|
||||
import { getAllPosts } from '@/lib/blog';
|
||||
import { Section, Container, Heading, Card, Badge, Button } from '@/components/ui';
|
||||
import Reveal from '@/components/Reveal';
|
||||
import { Metadata } from 'next';
|
||||
|
||||
import { getTranslations, setRequestLocale } from 'next-intl/server';
|
||||
import { SITE_URL } from '@/lib/schema';
|
||||
import { BlogPaginationKeyboardObserver } from '@/components/blog/BlogPaginationKeyboardObserver';
|
||||
@@ -12,6 +12,7 @@ interface BlogIndexProps {
|
||||
params: Promise<{
|
||||
locale: string;
|
||||
}>;
|
||||
searchParams: Promise<{ [key: string]: string | string[] | undefined }>;
|
||||
}
|
||||
|
||||
export async function generateMetadata({ params }: BlogIndexProps) {
|
||||
@@ -41,8 +42,10 @@ export async function generateMetadata({ params }: BlogIndexProps) {
|
||||
};
|
||||
}
|
||||
|
||||
export default async function BlogIndex({ params }: BlogIndexProps) {
|
||||
export default async function BlogIndex({ params, searchParams }: BlogIndexProps) {
|
||||
const { locale } = await params;
|
||||
const resolvedSearchParams = await searchParams;
|
||||
const categoryParam = resolvedSearchParams.category as string | undefined;
|
||||
setRequestLocale(locale);
|
||||
const t = await getTranslations('Blog');
|
||||
const posts = await getAllPosts(locale);
|
||||
@@ -52,8 +55,14 @@ export default async function BlogIndex({ params }: BlogIndexProps) {
|
||||
(a, b) => new Date(b.frontmatter.date).getTime() - new Date(a.frontmatter.date).getTime(),
|
||||
);
|
||||
|
||||
const featuredPost = sortedPosts[0];
|
||||
const remainingPosts = sortedPosts.slice(1);
|
||||
const filteredPosts = categoryParam
|
||||
? sortedPosts.filter(
|
||||
(post) => post.frontmatter.category?.toLowerCase() === categoryParam.toLowerCase(),
|
||||
)
|
||||
: sortedPosts;
|
||||
|
||||
const featuredPost = filteredPosts[0];
|
||||
const remainingPosts = filteredPosts.slice(1);
|
||||
|
||||
return (
|
||||
<div className="bg-neutral-light min-h-screen">
|
||||
@@ -123,31 +132,38 @@ export default async function BlogIndex({ params }: BlogIndexProps) {
|
||||
{t('allArticles')}
|
||||
</Heading>
|
||||
<div className="flex flex-wrap gap-2 md:gap-4">
|
||||
{/* Category filters could go here */}
|
||||
<Badge
|
||||
variant="primary"
|
||||
className="cursor-pointer hover:bg-primary hover:text-white transition-colors touch-target px-3 md:px-4"
|
||||
>
|
||||
{t('categories.all')}
|
||||
</Badge>
|
||||
<Badge
|
||||
variant="neutral"
|
||||
className="cursor-pointer hover:bg-primary hover:text-white transition-colors touch-target px-3 md:px-4"
|
||||
>
|
||||
{t('categories.industry')}
|
||||
</Badge>
|
||||
<Badge
|
||||
variant="neutral"
|
||||
className="cursor-pointer hover:bg-primary hover:text-white transition-colors touch-target px-3 md:px-4"
|
||||
>
|
||||
{t('categories.technical')}
|
||||
</Badge>
|
||||
<Badge
|
||||
variant="neutral"
|
||||
className="cursor-pointer hover:bg-primary hover:text-white transition-colors touch-target px-3 md:px-4"
|
||||
>
|
||||
{t('categories.sustainability')}
|
||||
</Badge>
|
||||
<Link href={`/${locale}/blog`}>
|
||||
<Badge
|
||||
variant={!categoryParam ? 'primary' : 'neutral'}
|
||||
className="cursor-pointer hover:bg-primary hover:text-white transition-colors touch-target px-3 md:px-4"
|
||||
>
|
||||
{t('categories.all')}
|
||||
</Badge>
|
||||
</Link>
|
||||
<Link href={`/${locale}/blog?category=industry`}>
|
||||
<Badge
|
||||
variant={categoryParam === 'industry' ? 'primary' : 'neutral'}
|
||||
className="cursor-pointer hover:bg-primary hover:text-white transition-colors touch-target px-3 md:px-4"
|
||||
>
|
||||
{t('categories.industry')}
|
||||
</Badge>
|
||||
</Link>
|
||||
<Link href={`/${locale}/blog?category=technical`}>
|
||||
<Badge
|
||||
variant={categoryParam === 'technical' ? 'primary' : 'neutral'}
|
||||
className="cursor-pointer hover:bg-primary hover:text-white transition-colors touch-target px-3 md:px-4"
|
||||
>
|
||||
{t('categories.technical')}
|
||||
</Badge>
|
||||
</Link>
|
||||
<Link href={`/${locale}/blog?category=sustainability`}>
|
||||
<Badge
|
||||
variant={categoryParam === 'sustainability' ? 'primary' : 'neutral'}
|
||||
className="cursor-pointer hover:bg-primary hover:text-white transition-colors touch-target px-3 md:px-4"
|
||||
>
|
||||
{t('categories.sustainability')}
|
||||
</Badge>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</Reveal>
|
||||
@@ -260,7 +276,7 @@ export default async function BlogIndex({ params }: BlogIndexProps) {
|
||||
{t('prev')}
|
||||
</Button>
|
||||
<Button
|
||||
href={`/${locale}/blog?page=1`}
|
||||
href={`/${locale}/blog?page=1${categoryParam ? `&category=${categoryParam}` : ''}`}
|
||||
variant="primary"
|
||||
size="sm"
|
||||
className="md:h-11 md:px-6 md:text-base"
|
||||
@@ -269,7 +285,7 @@ export default async function BlogIndex({ params }: BlogIndexProps) {
|
||||
1
|
||||
</Button>
|
||||
<Button
|
||||
href={`/${locale}/blog?page=2`}
|
||||
href={`/${locale}/blog?page=2${categoryParam ? `&category=${categoryParam}` : ''}`}
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="md:h-11 md:px-6 md:text-base"
|
||||
@@ -277,7 +293,7 @@ export default async function BlogIndex({ params }: BlogIndexProps) {
|
||||
2
|
||||
</Button>
|
||||
<Button
|
||||
href={`/${locale}/blog?page=2`}
|
||||
href={`/${locale}/blog?page=2${categoryParam ? `&category=${categoryParam}` : ''}`}
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="md:h-11 md:px-6 md:text-base"
|
||||
|
||||
@@ -22,6 +22,8 @@ import MeetTheTeam from './home/MeetTheTeam';
|
||||
import GallerySection from './home/GallerySection';
|
||||
import VideoSection from './home/VideoSection';
|
||||
import CTA from './home/CTA';
|
||||
import { AgbHistoryBlock } from './AgbHistoryBlock';
|
||||
import { PDFDownloadBlock } from './PDFDownloadBlock';
|
||||
function ContactSection(props: any) {
|
||||
return (
|
||||
<div className="p-8 border-2 border-dashed border-primary my-8 text-center text-primary font-bold">
|
||||
@@ -94,6 +96,10 @@ async function Block(props: any) {
|
||||
return <VideoSection data={data} />;
|
||||
case 'homeCTA':
|
||||
return <CTA data={data} />;
|
||||
case 'agbHistory':
|
||||
return <AgbHistoryBlock {...data} />;
|
||||
case 'pdfDownload':
|
||||
return <PDFDownloadBlock {...data} />;
|
||||
|
||||
default:
|
||||
return (
|
||||
|
||||
@@ -3,7 +3,11 @@
|
||||
import React from 'react';
|
||||
import { usePathname } from 'next/navigation';
|
||||
|
||||
export const PDFDownloadBlock: React.FC<{ label: string; style: string }> = ({ label, style }) => {
|
||||
export const PDFDownloadBlock: React.FC<{ label: string; style: string; url?: string }> = ({
|
||||
label,
|
||||
style,
|
||||
url,
|
||||
}) => {
|
||||
const pathname = usePathname();
|
||||
|
||||
// Extract slug from pathname
|
||||
@@ -12,7 +16,7 @@ export const PDFDownloadBlock: React.FC<{ label: string; style: string }> = ({ l
|
||||
// We want the page slug.
|
||||
const slug = segments[segments.length - 1] || 'home';
|
||||
|
||||
const href = `/api/pages/${slug}/pdf`;
|
||||
const href = url || `/api/pages/${slug}/pdf`;
|
||||
|
||||
return (
|
||||
<div className="my-8">
|
||||
|
||||
@@ -247,7 +247,7 @@ _status: published
|
||||
|
||||
Stand: April 2026
|
||||
|
||||
<Block type="pdfDownload" data={{"id":"69a9b09ea220fecfb9d54bb8","label":"Als PDF herunterladen","style":"primary","blockName":"","blockType":"pdfDownload"}} />
|
||||
<Block type="pdfDownload" data={{"id":"69a9b09ea220fecfb9d54bb8","label":"Als PDF herunterladen","style":"primary","url":"/AVB-KLZ-4-2026.pdf","blockName":"","blockType":"pdfDownload"}} />
|
||||
|
||||
<Block type="agbHistory" data={{"id":"69a9b09ea220fecfb9d54bb9","blockName":"AGB History","blockType":"agbHistory","title":"Vorherige Versionen"}} />
|
||||
|
||||
|
||||
9843
lib/TruckBlueprint.tsx
Normal file
9843
lib/TruckBlueprint.tsx
Normal file
File diff suppressed because one or more lines are too long
@@ -16,6 +16,24 @@ import {
|
||||
Circle,
|
||||
} from '@react-pdf/renderer';
|
||||
import * as path from 'path';
|
||||
import { TruckBlueprint } from './TruckBlueprint';
|
||||
|
||||
// Register embedded fonts so Ghostscript -dNoOutputFonts can convert them to
|
||||
// outlines (curves). Built-in PDF fonts like Helvetica are NEVER embedded and
|
||||
// therefore cannot be outlined — always use a physical font file here.
|
||||
Font.register({
|
||||
family: 'Inter',
|
||||
fonts: [
|
||||
{
|
||||
src: path.join(process.cwd(), 'public', 'fonts', 'Inter-Regular.woff'),
|
||||
fontWeight: 'normal',
|
||||
},
|
||||
{
|
||||
src: path.join(process.cwd(), 'public', 'fonts', 'Inter-Bold.woff'),
|
||||
fontWeight: 'bold',
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
export interface PersonData {
|
||||
name: string;
|
||||
@@ -28,11 +46,17 @@ export interface PDFBusinessCardProps {
|
||||
person: PersonData;
|
||||
qrCodeDataUrl: string;
|
||||
variant?: 'blue' | 'white' | 'black';
|
||||
isViaprinto?: boolean;
|
||||
withTruck?: boolean;
|
||||
withCropMarks?: boolean;
|
||||
}
|
||||
|
||||
// Mintel Aesthetics Colors - EXACT PROD COLORS
|
||||
// Die Buchstaben K, L, Z im SVG (inklusive dem kleinen Stück vom Z oben links)
|
||||
export const KLZ_PATHS = [25, 26, 27, 28, 29];
|
||||
|
||||
const COLORS = {
|
||||
primary: '#011dff', // Brand Royal Blue
|
||||
primary: '#000a66', // Brand Dark Blue (from website)
|
||||
blue: '#000a66',
|
||||
green: '#82ed20', // Brand Accent Green
|
||||
darkGreen: '#4A9E00', // Darker green for white background visibility
|
||||
white: '#ffffff',
|
||||
@@ -42,13 +66,105 @@ const COLORS = {
|
||||
lightGray: '#f8f9fa',
|
||||
};
|
||||
|
||||
const CropMarks = () => (
|
||||
<View style={{ position: 'absolute', top: 0, left: 0, right: 0, bottom: 0, zIndex: 9999 }}>
|
||||
{/* Top Left */}
|
||||
<View
|
||||
style={{
|
||||
position: 'absolute',
|
||||
left: '13mm',
|
||||
top: '0mm',
|
||||
width: '0.25pt',
|
||||
height: '10mm',
|
||||
backgroundColor: '#000',
|
||||
}}
|
||||
/>
|
||||
<View
|
||||
style={{
|
||||
position: 'absolute',
|
||||
left: '0mm',
|
||||
top: '13mm',
|
||||
width: '10mm',
|
||||
height: '0.25pt',
|
||||
backgroundColor: '#000',
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Top Right */}
|
||||
<View
|
||||
style={{
|
||||
position: 'absolute',
|
||||
right: '13mm',
|
||||
top: '0mm',
|
||||
width: '0.25pt',
|
||||
height: '10mm',
|
||||
backgroundColor: '#000',
|
||||
}}
|
||||
/>
|
||||
<View
|
||||
style={{
|
||||
position: 'absolute',
|
||||
right: '0mm',
|
||||
top: '13mm',
|
||||
width: '10mm',
|
||||
height: '0.25pt',
|
||||
backgroundColor: '#000',
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Bottom Left */}
|
||||
<View
|
||||
style={{
|
||||
position: 'absolute',
|
||||
left: '13mm',
|
||||
bottom: '0mm',
|
||||
width: '0.25pt',
|
||||
height: '10mm',
|
||||
backgroundColor: '#000',
|
||||
}}
|
||||
/>
|
||||
<View
|
||||
style={{
|
||||
position: 'absolute',
|
||||
left: '0mm',
|
||||
bottom: '13mm',
|
||||
width: '10mm',
|
||||
height: '0.25pt',
|
||||
backgroundColor: '#000',
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Bottom Right */}
|
||||
<View
|
||||
style={{
|
||||
position: 'absolute',
|
||||
right: '13mm',
|
||||
bottom: '0mm',
|
||||
width: '0.25pt',
|
||||
height: '10mm',
|
||||
backgroundColor: '#000',
|
||||
}}
|
||||
/>
|
||||
<View
|
||||
style={{
|
||||
position: 'absolute',
|
||||
right: '0mm',
|
||||
bottom: '13mm',
|
||||
width: '10mm',
|
||||
height: '0.25pt',
|
||||
backgroundColor: '#000',
|
||||
}}
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
pageBleed: {
|
||||
width: '91mm',
|
||||
height: '61mm',
|
||||
backgroundColor: COLORS.primary,
|
||||
position: 'relative',
|
||||
fontFamily: 'Helvetica',
|
||||
fontFamily: 'Inter',
|
||||
overflow: 'hidden',
|
||||
},
|
||||
pageBleedBack: {
|
||||
@@ -56,7 +172,7 @@ const styles = StyleSheet.create({
|
||||
height: '61mm',
|
||||
backgroundColor: COLORS.white,
|
||||
position: 'relative',
|
||||
fontFamily: 'Helvetica',
|
||||
fontFamily: 'Inter',
|
||||
overflow: 'hidden',
|
||||
},
|
||||
|
||||
@@ -66,100 +182,97 @@ const styles = StyleSheet.create({
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: '16mm', // Leave space for the bar below
|
||||
bottom: '20mm', // Truck stops above the footer area to avoid text conflict
|
||||
zIndex: 1,
|
||||
},
|
||||
frontBackgroundImage: {
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
objectFit: 'contain',
|
||||
objectPositionX: 'center',
|
||||
objectPositionY: 'center',
|
||||
opacity: 0.4, // Less transparent, more contrast
|
||||
},
|
||||
frontContainer: {
|
||||
flex: 1,
|
||||
paddingBottom: '16mm', // Push the logo up to center it in the area above the bar
|
||||
paddingTop: '3mm', // Compensate for top bleed for perfect optical centering
|
||||
paddingBottom: '20mm', // Leave space for the footer area
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
},
|
||||
frontLogo: {
|
||||
width: 175,
|
||||
width: 145, // Reduced for a more elegant, premium look with more negative space
|
||||
zIndex: 100,
|
||||
},
|
||||
frontTagline: {
|
||||
color: COLORS.white,
|
||||
fontSize: 6,
|
||||
marginTop: 10,
|
||||
letterSpacing: 1.5,
|
||||
fontSize: 5.5,
|
||||
marginTop: 8,
|
||||
letterSpacing: 2,
|
||||
fontWeight: 'bold',
|
||||
textTransform: 'uppercase',
|
||||
zIndex: 100,
|
||||
},
|
||||
frontGreenBarBottom: {
|
||||
frontFooterArea: {
|
||||
position: 'absolute',
|
||||
bottom: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
height: '16mm',
|
||||
borderTop: `2pt solid ${COLORS.green}`,
|
||||
backgroundColor: '#000a66',
|
||||
height: '22mm', // Increased height to accommodate more bottom padding
|
||||
paddingBottom: '10mm', // Increased bottom padding to move icons further from edge
|
||||
zIndex: 50,
|
||||
flexDirection: 'column',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
gap: 6, // Increased gap for more spacing under tagline
|
||||
gap: 6, // Harmonious spacing to icons
|
||||
},
|
||||
frontIconsGroup: {
|
||||
flexDirection: 'row',
|
||||
gap: 12,
|
||||
gap: 14, // Wider gaps between icon groups for visual clarity at small size
|
||||
},
|
||||
frontBrandIconWrapper: {
|
||||
alignItems: 'center',
|
||||
gap: 2,
|
||||
gap: 1.5,
|
||||
},
|
||||
frontBrandIcon: {
|
||||
width: 18,
|
||||
height: 18,
|
||||
opacity: 0.9,
|
||||
width: 12, // Smaller for compact bar
|
||||
height: 12,
|
||||
opacity: 0.85,
|
||||
},
|
||||
frontBrandText: {
|
||||
fontSize: 5.5,
|
||||
fontSize: 4, // Tiny but readable at 300dpi print
|
||||
color: COLORS.white,
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: 0.5,
|
||||
opacity: 0.9,
|
||||
letterSpacing: 0.6,
|
||||
// Full opacity — white on blue needs to pop cleanly
|
||||
},
|
||||
frontTaglineBar: {
|
||||
color: COLORS.green,
|
||||
fontSize: 7,
|
||||
letterSpacing: 1.5,
|
||||
fontSize: 7, // Larger — as requested
|
||||
letterSpacing: 0.5, // Harmonious tracking for mixed case
|
||||
fontWeight: 'bold',
|
||||
textTransform: 'uppercase',
|
||||
},
|
||||
|
||||
// -- WHITE PAGE (Vorderseite) --
|
||||
backContainer: {
|
||||
flex: 1,
|
||||
padding: '8mm',
|
||||
paddingTop: '6mm', // Pushed down as requested
|
||||
paddingLeft: '6mm',
|
||||
paddingRight: '6mm',
|
||||
flexDirection: 'column',
|
||||
justifyContent: 'space-between',
|
||||
},
|
||||
backHeader: {
|
||||
marginBottom: 'auto',
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
marginBottom: '5mm', // Generous breathing room
|
||||
width: '100%',
|
||||
},
|
||||
backLogo: {
|
||||
width: 70,
|
||||
width: 60,
|
||||
},
|
||||
accentLineHorizontal: {
|
||||
flex: 1,
|
||||
height: 1.5,
|
||||
backgroundColor: COLORS.primary, // Brand blue line
|
||||
marginLeft: 15,
|
||||
marginRight: 0,
|
||||
marginLeft: 12,
|
||||
marginRight: 12,
|
||||
},
|
||||
accentDot: {
|
||||
width: 4,
|
||||
@@ -178,41 +291,41 @@ const styles = StyleSheet.create({
|
||||
top: -5,
|
||||
},
|
||||
backBody: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'flex-end',
|
||||
flex: 1,
|
||||
flexDirection: 'column',
|
||||
justifyContent: 'flex-start',
|
||||
alignItems: 'flex-start',
|
||||
width: '100%',
|
||||
},
|
||||
leftColumn: {
|
||||
flex: 1,
|
||||
width: '100%', // Take full width since right column is removed
|
||||
flexDirection: 'column',
|
||||
paddingRight: '4mm',
|
||||
paddingBottom: '2mm',
|
||||
},
|
||||
nameSection: {
|
||||
marginBottom: '5mm',
|
||||
marginBottom: '2.5mm', // Reduced to fix the 'Lücke' under the role
|
||||
},
|
||||
nameText: {
|
||||
fontSize: 16,
|
||||
fontWeight: 'bold',
|
||||
color: COLORS.neutralDark,
|
||||
marginBottom: 2,
|
||||
letterSpacing: -0.2,
|
||||
},
|
||||
roleText: {
|
||||
fontSize: 9,
|
||||
fontSize: 13,
|
||||
fontWeight: 'bold',
|
||||
color: COLORS.primary,
|
||||
letterSpacing: 0.5,
|
||||
marginBottom: 1.5,
|
||||
letterSpacing: -0.15,
|
||||
},
|
||||
roleText: {
|
||||
fontSize: 6.5,
|
||||
fontWeight: 'bold',
|
||||
color: COLORS.darkGreen,
|
||||
letterSpacing: 1.2,
|
||||
textTransform: 'uppercase',
|
||||
},
|
||||
contactSection: {
|
||||
flexDirection: 'column',
|
||||
gap: 5,
|
||||
gap: 0,
|
||||
},
|
||||
contactRow: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
marginBottom: 5, // Even rhythm between contact lines
|
||||
},
|
||||
iconWrapper: {
|
||||
width: 12,
|
||||
@@ -220,35 +333,48 @@ const styles = StyleSheet.create({
|
||||
alignItems: 'flex-start',
|
||||
},
|
||||
contactText: {
|
||||
fontSize: 9,
|
||||
color: COLORS.neutralDark,
|
||||
fontSize: 7.5,
|
||||
color: COLORS.primary,
|
||||
fontWeight: 'normal',
|
||||
paddingTop: 1,
|
||||
paddingTop: 0.5,
|
||||
letterSpacing: 0.1,
|
||||
},
|
||||
rightColumn: {
|
||||
flexDirection: 'column',
|
||||
alignItems: 'flex-end',
|
||||
justifyContent: 'flex-end',
|
||||
justifyContent: 'flex-start',
|
||||
},
|
||||
// Ultra Minimalist QR Area
|
||||
qrContainer: {
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
gap: 4,
|
||||
gap: 3,
|
||||
},
|
||||
qrCodeWrapper: {
|
||||
width: '15mm',
|
||||
height: '15mm',
|
||||
width: '10mm',
|
||||
height: '10mm',
|
||||
border: `0.75pt solid ${COLORS.darkGreen}`,
|
||||
padding: 2,
|
||||
backgroundColor: '#ffffff',
|
||||
},
|
||||
qrCode: {
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
},
|
||||
qrLabel: {
|
||||
fontSize: 5,
|
||||
color: COLORS.neutralDark, // Subtle grey instead of loud blue
|
||||
fontWeight: 'bold',
|
||||
letterSpacing: 1.2,
|
||||
addressFooter: {
|
||||
width: '100%',
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'flex-start',
|
||||
alignItems: 'center',
|
||||
position: 'absolute',
|
||||
bottom: '7mm', // 3mm bleed + 4mm safe margin
|
||||
left: '6mm', // Match backContainer paddingLeft for flush alignment
|
||||
},
|
||||
addressFooterText: {
|
||||
fontSize: 6.5,
|
||||
color: COLORS.primary,
|
||||
letterSpacing: 0.4,
|
||||
fontWeight: 'normal',
|
||||
},
|
||||
});
|
||||
|
||||
@@ -257,7 +383,7 @@ const PhoneIcon = () => (
|
||||
<Svg viewBox="0 0 24 24" width={9} height={9}>
|
||||
<Path
|
||||
d="M6.62 10.79c1.44 2.83 3.76 5.14 6.59 6.59l2.2-2.2c.27-.27.67-.36 1.02-.24 1.12.37 2.33.57 3.57.57.55 0 1 .45 1 1V20c0 .55-.45 1-1 1-9.39 0-17-7.61-17-17 0-.55.45-1 1-1h3.5c.55 0 1 .45 1 1 0 1.25.2 2.45.57 3.57.11.35.03.74-.25 1.02l-2.2 2.2z"
|
||||
fill="#000000"
|
||||
fill={COLORS.primary}
|
||||
/>
|
||||
</Svg>
|
||||
);
|
||||
@@ -266,7 +392,7 @@ const EmailIcon = () => (
|
||||
<Svg viewBox="0 0 24 24" width={9} height={9}>
|
||||
<Path
|
||||
d="M20 4H4c-1.1 0-1.99.9-1.99 2L2 18c0 1.1.9 2 2 2h16c1.1 0 2-.9 2-2V6c0-1.1-.9-2-2-2zm0 4l-8 5-8-5V6l8 5 8-5v2z"
|
||||
fill="#000000"
|
||||
fill={COLORS.primary}
|
||||
/>
|
||||
</Svg>
|
||||
);
|
||||
@@ -275,7 +401,16 @@ const GlobeIcon = () => (
|
||||
<Svg viewBox="0 0 24 24" width={9} height={9}>
|
||||
<Path
|
||||
d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm-1 17.93c-3.95-.49-7-3.85-7-7.93 0-.62.08-1.21.21-1.79L9 15v1c0 1.1.9 2 2 2v1.93zm6.9-2.54c-.26-.81-1-1.39-1.9-1.39h-1v-3c0-.55-.45-1-1-1H8v-2h2c.55 0 1-.45 1-1V7h2c1.1 0 2-.9 2-2v-.41c2.93 1.19 5 4.06 5 7.41 0 2.08-.8 3.97-2.1 5.39z"
|
||||
fill="#000000"
|
||||
fill={COLORS.primary}
|
||||
/>
|
||||
</Svg>
|
||||
);
|
||||
|
||||
const LocationIcon = () => (
|
||||
<Svg viewBox="0 0 24 24" width={9} height={9}>
|
||||
<Path
|
||||
d="M12 2C8.13 2 5 5.13 5 9c0 5.25 7 13 7 13s7-7.75 7-13c0-3.87-3.13-7-7-7zm0 9.5c-1.38 0-2.5-1.12-2.5-2.5s1.12-2.5 2.5-2.5 2.5 1.12 2.5 2.5-1.12 2.5-2.5 2.5z"
|
||||
fill={COLORS.primary}
|
||||
/>
|
||||
</Svg>
|
||||
);
|
||||
@@ -284,7 +419,7 @@ const ScanIcon = () => (
|
||||
<Svg viewBox="0 0 24 24" width={9} height={9}>
|
||||
<Path
|
||||
d="M3 3h6v2H5v4H3V3zm18 0h-6v2h4v4h2V3zM3 21h6v-2H5v-4H3v6zm18 0h-6v-2h4v-4h2v6zM7 7h10v10H7V7z"
|
||||
fill="#000000"
|
||||
fill={COLORS.primary}
|
||||
/>
|
||||
</Svg>
|
||||
);
|
||||
@@ -315,12 +450,58 @@ const parseSvgPaths = (filePath: string) => {
|
||||
}
|
||||
};
|
||||
|
||||
const VectorLogo = ({ paths, style }: { paths: { d: string; fill: string }[]; style: any }) => (
|
||||
const VectorLogo = ({
|
||||
paths,
|
||||
style,
|
||||
overrideColor,
|
||||
overrideWhiteColor,
|
||||
overrideBlackColor,
|
||||
pathOverrides,
|
||||
solidPaths,
|
||||
solidBackgroundColor = '#000a66',
|
||||
}: {
|
||||
paths: { d: string; fill: string }[];
|
||||
style: any;
|
||||
overrideColor?: string;
|
||||
overrideWhiteColor?: string;
|
||||
overrideBlackColor?: string;
|
||||
pathOverrides?: Record<number, string>;
|
||||
solidPaths?: number[];
|
||||
solidBackgroundColor?: string;
|
||||
}) => (
|
||||
<Svg viewBox="0 0 295 99" style={style}>
|
||||
<G {...({ fillRule: 'evenodd', clipRule: 'evenodd' } as any)}>
|
||||
{paths.map((p, i) => (
|
||||
<Path key={i} d={p.d} fill={p.fill} />
|
||||
))}
|
||||
{/* Background layer: solid fills to prevent see-through */}
|
||||
{solidPaths &&
|
||||
solidBackgroundColor &&
|
||||
paths.map((p, i) => {
|
||||
if (!solidPaths.includes(i)) return null;
|
||||
let d = p.d;
|
||||
const zIndex = d.toUpperCase().indexOf('Z');
|
||||
if (zIndex !== -1) {
|
||||
d = d.substring(0, zIndex + 1);
|
||||
}
|
||||
return <Path key={`bg-${i}`} d={d} fill={solidBackgroundColor} />;
|
||||
})}
|
||||
{paths.map((p, i) => {
|
||||
let fill = overrideColor || p.fill;
|
||||
const d = p.d;
|
||||
|
||||
if (pathOverrides && pathOverrides[i]) {
|
||||
fill = pathOverrides[i];
|
||||
} else if (
|
||||
overrideWhiteColor &&
|
||||
(p.fill === 'white' || p.fill === '#ffffff' || p.fill === '#fff')
|
||||
) {
|
||||
fill = overrideWhiteColor;
|
||||
} else if (
|
||||
overrideBlackColor &&
|
||||
(p.fill === 'black' || p.fill === '#000000' || p.fill === '#0a0a0a' || p.fill === '#000')
|
||||
) {
|
||||
fill = overrideBlackColor;
|
||||
}
|
||||
return <Path key={i} d={d} fill={fill} />;
|
||||
})}
|
||||
</G>
|
||||
</Svg>
|
||||
);
|
||||
@@ -550,7 +731,7 @@ export const IconHighVoltage = ({ style, color = 'white' }: { style: any; color?
|
||||
style={{ fill: 'none', stroke: color, strokeWidth: '0.33px' }}
|
||||
/>
|
||||
<Path
|
||||
d="M28.597,16.426c-0,-1.03 -0.837,-1.867 -1.868,-1.867c-1.03,0 -1.867,0.837 -1.867,1.867c0,1.031 0.837,1.868 1.867,1.868c1.031,-0 1.868,-0.837 1.868,-1.868Z"
|
||||
d="M28.597,16.426c-0,-1.03 -0.837,-1.868 -1.868,-1.867c-1.03,0 -1.867,0.837 -1.867,1.867c0,1.031 0.837,1.868 1.867,1.868c1.031,-0 1.868,-0.837 1.868,-1.868Z"
|
||||
style={{ fill: 'none', stroke: color, strokeWidth: '0.33px' }}
|
||||
/>
|
||||
<Path
|
||||
@@ -610,6 +791,9 @@ export const PDFBusinessCard: React.FC<PDFBusinessCardProps> = ({
|
||||
person,
|
||||
qrCodeDataUrl,
|
||||
variant = 'blue',
|
||||
isViaprinto = false,
|
||||
withTruck = true,
|
||||
withCropMarks = false,
|
||||
}) => {
|
||||
const isWhiteVariant = variant === 'white' || variant === 'black';
|
||||
|
||||
@@ -637,10 +821,38 @@ export const PDFBusinessCard: React.FC<PDFBusinessCardProps> = ({
|
||||
return logoWhitePaths;
|
||||
};
|
||||
|
||||
const getBackLogoPaths = () => {
|
||||
// We use the original black logo to preserve the exact colors of the Truck and KLZ.
|
||||
// The tagline will be hidden via pathOverrides in the JSX.
|
||||
return logoBlackWithTaglinePaths;
|
||||
};
|
||||
|
||||
const bgImagePath = isWhiteVariant
|
||||
? path.join(process.cwd(), 'public', 'media', 'cable_drums_truck.png')
|
||||
: path.join(process.cwd(), 'public', 'cable_drums_bg.png');
|
||||
|
||||
const mmToPt = (mm: number) => (mm * 72) / 25.4;
|
||||
|
||||
const renderPageWrapper = (children: React.ReactNode, bleedStyle: any) => {
|
||||
if (withCropMarks) {
|
||||
return (
|
||||
<Page size={[315, 230]} style={{ backgroundColor: '#ffffff' }}>
|
||||
<View style={{ flex: 1, justifyContent: 'center', alignItems: 'center' }}>
|
||||
<CropMarks />
|
||||
<View style={[bleedStyle, { overflow: 'hidden', position: 'relative' }]}>
|
||||
{children}
|
||||
</View>
|
||||
</View>
|
||||
</Page>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<Page size={[mmToPt(91), mmToPt(61)]} style={bleedStyle}>
|
||||
{children}
|
||||
</Page>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<Document
|
||||
title={`Business Card - ${person.name}`}
|
||||
@@ -648,75 +860,90 @@ export const PDFBusinessCard: React.FC<PDFBusinessCardProps> = ({
|
||||
creator="KLZ Print Engine"
|
||||
>
|
||||
{/* VORDERSEITE (Blue page with logo, truck and tagline) */}
|
||||
<Page
|
||||
size={[258, 173]}
|
||||
style={[styles.pageBleed, isWhiteVariant && { backgroundColor: '#ffffff' }]}
|
||||
>
|
||||
<View style={styles.truckWrapper}>
|
||||
<Image
|
||||
src={bgImagePath}
|
||||
style={[styles.frontBackgroundImage, isWhiteVariant && { opacity: 0.2 }]}
|
||||
/>
|
||||
</View>
|
||||
<View
|
||||
style={[
|
||||
styles.frontGreenBarBottom,
|
||||
isWhiteVariant && { backgroundColor: '#ffffff', borderTopColor: COLORS.darkGreen },
|
||||
]}
|
||||
>
|
||||
<Text style={[styles.frontTaglineBar, isWhiteVariant && { color: COLORS.black }]}>
|
||||
VON 0,6/1,0 KV BIS 64/110 KV
|
||||
</Text>
|
||||
<View style={styles.frontIconsGroup}>
|
||||
<View style={styles.frontBrandIconWrapper}>
|
||||
<IconLowVoltage
|
||||
style={styles.frontBrandIcon}
|
||||
color={isWhiteVariant ? COLORS.black : 'white'}
|
||||
/>
|
||||
<Text style={[styles.frontBrandText, isWhiteVariant && { color: COLORS.black }]}>
|
||||
Niederspannung
|
||||
</Text>
|
||||
</View>
|
||||
<View style={styles.frontBrandIconWrapper}>
|
||||
<IconMediumVoltage
|
||||
style={styles.frontBrandIcon}
|
||||
color={isWhiteVariant ? COLORS.black : 'white'}
|
||||
/>
|
||||
<Text style={[styles.frontBrandText, isWhiteVariant && { color: COLORS.black }]}>
|
||||
Mittelspannung
|
||||
</Text>
|
||||
</View>
|
||||
<View style={styles.frontBrandIconWrapper}>
|
||||
<IconHighVoltage
|
||||
style={styles.frontBrandIcon}
|
||||
color={isWhiteVariant ? COLORS.black : 'white'}
|
||||
/>
|
||||
<Text style={[styles.frontBrandText, isWhiteVariant && { color: COLORS.black }]}>
|
||||
Hochspannung
|
||||
</Text>
|
||||
</View>
|
||||
<View style={styles.frontBrandIconWrapper}>
|
||||
<IconSolar
|
||||
style={styles.frontBrandIcon}
|
||||
color={isWhiteVariant ? COLORS.black : 'white'}
|
||||
/>
|
||||
<Text style={[styles.frontBrandText, isWhiteVariant && { color: COLORS.black }]}>
|
||||
Solar
|
||||
</Text>
|
||||
{renderPageWrapper(
|
||||
<>
|
||||
<View style={styles.frontFooterArea}>
|
||||
<Text style={[styles.frontTaglineBar, isWhiteVariant && { color: COLORS.black }]}>
|
||||
Von 0,6/1,0 kV bis 64/110 kV
|
||||
</Text>
|
||||
<View style={styles.frontIconsGroup}>
|
||||
<View style={styles.frontBrandIconWrapper}>
|
||||
<IconLowVoltage
|
||||
style={styles.frontBrandIcon}
|
||||
color={isWhiteVariant ? COLORS.black : 'white'}
|
||||
/>
|
||||
<Text style={[styles.frontBrandText, isWhiteVariant && { color: COLORS.black }]}>
|
||||
Niederspannung
|
||||
</Text>
|
||||
</View>
|
||||
<View style={styles.frontBrandIconWrapper}>
|
||||
<IconMediumVoltage
|
||||
style={styles.frontBrandIcon}
|
||||
color={isWhiteVariant ? COLORS.black : 'white'}
|
||||
/>
|
||||
<Text style={[styles.frontBrandText, isWhiteVariant && { color: COLORS.black }]}>
|
||||
Mittelspannung
|
||||
</Text>
|
||||
</View>
|
||||
<View style={styles.frontBrandIconWrapper}>
|
||||
<IconHighVoltage
|
||||
style={styles.frontBrandIcon}
|
||||
color={isWhiteVariant ? COLORS.black : 'white'}
|
||||
/>
|
||||
<Text style={[styles.frontBrandText, isWhiteVariant && { color: COLORS.black }]}>
|
||||
Hochspannung
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
<View style={styles.frontBrandIconWrapper}>
|
||||
<IconSolar
|
||||
style={styles.frontBrandIcon}
|
||||
color={isWhiteVariant ? COLORS.black : 'white'}
|
||||
/>
|
||||
<Text style={[styles.frontBrandText, isWhiteVariant && { color: COLORS.black }]}>
|
||||
Solar
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
<View style={styles.frontContainer}>
|
||||
<VectorLogo paths={getFrontLogoPaths()} style={styles.frontLogo} />
|
||||
</View>
|
||||
</Page>
|
||||
<View style={styles.frontContainer}>
|
||||
{/* Base Logo: text is white, truck is green. Fill KLZ background to hide truck, keep outline white */}
|
||||
<VectorLogo
|
||||
paths={getFrontLogoPaths()}
|
||||
style={styles.frontLogo}
|
||||
solidPaths={KLZ_PATHS}
|
||||
solidBackgroundColor={isWhiteVariant ? '#ffffff' : COLORS.primary}
|
||||
/>
|
||||
</View>
|
||||
</>,
|
||||
[styles.pageBleed, isWhiteVariant && { backgroundColor: '#ffffff' }],
|
||||
)}
|
||||
|
||||
{/* RÜCKSEITE (White page with contact info) */}
|
||||
<Page size={[258, 173]} style={styles.pageBleedBack}>
|
||||
{renderPageWrapper(
|
||||
<View style={styles.backContainer}>
|
||||
<View style={styles.backHeader}>
|
||||
<VectorLogo paths={logoBluePaths} style={styles.backLogo} />
|
||||
<VectorLogo
|
||||
paths={getBackLogoPaths()}
|
||||
style={styles.backLogo}
|
||||
overrideBlackColor={COLORS.primary}
|
||||
pathOverrides={{
|
||||
...(KLZ_PATHS.reduce(
|
||||
(acc: Record<number, string>, idx) => ({ ...acc, [idx]: 'none' }),
|
||||
{} as Record<number, string>,
|
||||
) as any),
|
||||
// Paths 0-24 are the "Cables for a greener tomorrow" tagline in the 40-path logo.
|
||||
...(Array.from({ length: 25 }).reduce(
|
||||
(acc: Record<number, string>, _, i) => ({ ...acc, [i]: 'none' }),
|
||||
{} as Record<number, string>,
|
||||
) as any),
|
||||
}}
|
||||
/>
|
||||
<View style={styles.accentLineHorizontal} />
|
||||
<View style={styles.qrContainer}>
|
||||
<View style={[styles.qrCodeWrapper, { borderColor: COLORS.darkGreen }]}>
|
||||
<Image src={qrCodeDataUrl} style={styles.qrCode} />
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<View style={styles.backBody}>
|
||||
@@ -737,7 +964,7 @@ export const PDFBusinessCard: React.FC<PDFBusinessCardProps> = ({
|
||||
<View style={styles.iconWrapper}>
|
||||
<EmailIcon />
|
||||
</View>
|
||||
<Text style={styles.contactText}>{person.email}</Text>
|
||||
<Text style={styles.contactText}>{person.email.toLowerCase()}</Text>
|
||||
</View>
|
||||
<View style={styles.contactRow}>
|
||||
<View style={styles.iconWrapper}>
|
||||
@@ -747,18 +974,16 @@ export const PDFBusinessCard: React.FC<PDFBusinessCardProps> = ({
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<View style={styles.rightColumn}>
|
||||
<View style={styles.qrContainer}>
|
||||
<View style={styles.qrCodeWrapper}>
|
||||
<Image src={qrCodeDataUrl} style={styles.qrCode} />
|
||||
</View>
|
||||
<Text style={styles.qrLabel}>ABSPEICHERN</Text>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
</Page>
|
||||
|
||||
<View style={styles.addressFooter}>
|
||||
<Text style={styles.addressFooterText}>
|
||||
KLZ Vertriebs GmbH · Raiffeisenstraße 22 · 73630 Remshalden
|
||||
</Text>
|
||||
</View>
|
||||
</View>,
|
||||
styles.pageBleedBack,
|
||||
)}
|
||||
</Document>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -329,6 +329,42 @@ const renderLexicalNode = (node: any, idx: number): React.ReactNode => {
|
||||
}
|
||||
};
|
||||
|
||||
const renderMarkdown = (markdown: string) => {
|
||||
const blocks = markdown.split('\n\n').filter((b) => b.trim());
|
||||
return blocks.map((block, idx) => {
|
||||
if (block.startsWith('### ')) {
|
||||
return (
|
||||
<Text key={idx} style={styles.heading3} wrap={false} minPresenceAhead={100}>
|
||||
{block.replace('### ', '').trim()}
|
||||
</Text>
|
||||
);
|
||||
}
|
||||
if (block.startsWith('## ')) {
|
||||
return (
|
||||
<Text key={idx} style={styles.heading2} wrap={false} minPresenceAhead={100}>
|
||||
{block.replace('## ', '').trim()}
|
||||
</Text>
|
||||
);
|
||||
}
|
||||
if (block.startsWith('# ')) {
|
||||
return (
|
||||
<View key={idx} style={styles.heading1Wrapper} wrap={false} minPresenceAhead={100}>
|
||||
<Text style={styles.heading1}>{block.replace('# ', '').trim()}</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
// Ignore block components like <Block ... />
|
||||
if (block.startsWith('<Block')) return null;
|
||||
|
||||
return (
|
||||
<Text key={idx} style={styles.paragraph} minPresenceAhead={15}>
|
||||
{block.trim()}
|
||||
</Text>
|
||||
);
|
||||
});
|
||||
};
|
||||
|
||||
interface PDFPageProps {
|
||||
page: any;
|
||||
locale?: string;
|
||||
@@ -360,9 +396,11 @@ export const PDFPage: React.FC<PDFPageProps> = ({ page, locale = 'de' }) => {
|
||||
|
||||
<View style={styles.content}>
|
||||
<View>
|
||||
{page.content?.root?.children?.map((node: any, i: number) =>
|
||||
renderLexicalNode(node, i),
|
||||
)}
|
||||
{typeof page.content === 'string'
|
||||
? renderMarkdown(page.content)
|
||||
: page.content?.root?.children?.map((node: any, i: number) =>
|
||||
renderLexicalNode(node, i),
|
||||
)}
|
||||
</View>
|
||||
</View>
|
||||
|
||||
|
||||
@@ -116,7 +116,7 @@
|
||||
"prepare": "husky",
|
||||
"preinstall": "npx only-allow pnpm"
|
||||
},
|
||||
"version": "2.3.23",
|
||||
"version": "2.3.28",
|
||||
"pnpm": {
|
||||
"onlyBuiltDependencies": [
|
||||
"@parcel/watcher",
|
||||
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
10
public/v/johannesgleich.vcf
Normal file
10
public/v/johannesgleich.vcf
Normal file
@@ -0,0 +1,10 @@
|
||||
BEGIN:VCARD
|
||||
VERSION:3.0
|
||||
N:Gleich;Johannes;;;
|
||||
FN:Johannes Gleich
|
||||
ORG:KLZ Vertriebs GmbH
|
||||
TITLE:Senior Key Account Manager
|
||||
TEL;TYPE=WORK,VOICE:+49 172 739 1109
|
||||
EMAIL;TYPE=PREF,INTERNET:johannes.gleich@klz-cables.com
|
||||
URL:https://klz-cables.com
|
||||
END:VCARD
|
||||
10
public/v/klausmintel.vcf
Normal file
10
public/v/klausmintel.vcf
Normal file
@@ -0,0 +1,10 @@
|
||||
BEGIN:VCARD
|
||||
VERSION:3.0
|
||||
N:Mintel;Klaus;;;
|
||||
FN:Klaus Mintel
|
||||
ORG:KLZ Vertriebs GmbH
|
||||
TITLE:Managing Director
|
||||
TEL;TYPE=WORK,VOICE:+49 151 1775 2873
|
||||
EMAIL;TYPE=PREF,INTERNET:klaus.mintel@klz-cables.com
|
||||
URL:https://klz-cables.com
|
||||
END:VCARD
|
||||
10
public/v/michaelbodemer.vcf
Normal file
10
public/v/michaelbodemer.vcf
Normal file
@@ -0,0 +1,10 @@
|
||||
BEGIN:VCARD
|
||||
VERSION:3.0
|
||||
N:Bodemer;Michael;;;
|
||||
FN:Michael Bodemer
|
||||
ORG:KLZ Vertriebs GmbH
|
||||
TITLE:Owner & Managing Director
|
||||
TEL;TYPE=WORK,VOICE:+49 151 462 467 98
|
||||
EMAIL;TYPE=PREF,INTERNET:michael.bodemer@klz-cables.com
|
||||
URL:https://klz-cables.com
|
||||
END:VCARD
|
||||
@@ -19,13 +19,15 @@ const CONFIG = {
|
||||
truckImage: path.join(process.cwd(), 'public/truck.png'),
|
||||
fontRegular: path.join(process.cwd(), 'public/fonts/Inter-Regular.ttf'),
|
||||
fontBold: path.join(process.cwd(), 'public/fonts/Inter-Bold.ttf'),
|
||||
ghostscriptScript:
|
||||
'/Users/marcmintel/.gemini/config/plugins/mintel-ui-standards-plugin/skills/mintel-print-workflow/scripts/convert-cmyk.sh',
|
||||
};
|
||||
|
||||
const PEOPLE: PersonData[] = [
|
||||
{
|
||||
name: 'Michael Bodemer',
|
||||
role: 'Managing Director',
|
||||
phone: '+49 171 785 2420',
|
||||
role: 'Owner & Managing Director',
|
||||
phone: '+49 151 462 467 98',
|
||||
email: 'michael.bodemer@klz-cables.com',
|
||||
},
|
||||
{
|
||||
@@ -36,8 +38,8 @@ const PEOPLE: PersonData[] = [
|
||||
},
|
||||
{
|
||||
name: 'Johannes Gleich',
|
||||
role: 'Platzhalter Rolle',
|
||||
phone: '+49 000 0000000',
|
||||
role: 'Senior Key Account Manager',
|
||||
phone: '+49 172 739 1109',
|
||||
email: 'johannes.gleich@klz-cables.com',
|
||||
},
|
||||
];
|
||||
@@ -50,90 +52,148 @@ async function generateBusinessCards() {
|
||||
for (const person of PEOPLE) {
|
||||
console.log(`\nGenerating Business Card for: ${person.name}`);
|
||||
|
||||
// Generate VCard QR Code
|
||||
const lastName = person.name.split(' ').slice(1).join(' ');
|
||||
const firstName = person.name.split(' ')[0];
|
||||
const vcard = `BEGIN:VCARD
|
||||
VERSION:3.0
|
||||
N:${lastName};${firstName};;;
|
||||
FN:${person.name}
|
||||
ORG:KLZ Vertriebs GmbH
|
||||
TITLE:${person.role}
|
||||
TEL;TYPE=WORK,VOICE:${person.phone}
|
||||
EMAIL;TYPE=PREF,INTERNET:${person.email}
|
||||
URL:https://klz-cables.com
|
||||
END:VCARD`;
|
||||
|
||||
const qrCodeDataUrl = await QRCode.toDataURL(vcard, {
|
||||
// To make the QR code extremely coarse (minimalist), we cannot encode all contact details into it directly.
|
||||
// Instead, we create a physical .vcf file and encode ONLY its URL into the QR Code (e.g. 35 chars vs 140 chars).
|
||||
const shortId = person.name.toLowerCase().replace(/[^a-z0-9]/g, ''); // "michaelbodemer"
|
||||
const vcfUrl = `https://klz-cables.com/v/${shortId}.vcf`;
|
||||
|
||||
const vcardFull = `BEGIN:VCARD\r\nVERSION:3.0\r\nN:${lastName};${firstName};;;\r\nFN:${person.name}\r\nORG:KLZ Vertriebs GmbH\r\nTITLE:${person.role}\r\nTEL;TYPE=WORK,VOICE:${person.phone}\r\nEMAIL;TYPE=PREF,INTERNET:${person.email}\r\nURL:https://klz-cables.com\r\nEND:VCARD`;
|
||||
|
||||
// Save the physical .vcf file so it can be served by Next.js from /public/v/
|
||||
const vcardDir = path.join(process.cwd(), 'public/v');
|
||||
if (!fs.existsSync(vcardDir)) fs.mkdirSync(vcardDir, { recursive: true });
|
||||
fs.writeFileSync(path.join(vcardDir, `${shortId}.vcf`), vcardFull);
|
||||
|
||||
const qrCodeDataUrl = await QRCode.toDataURL(vcfUrl, {
|
||||
width: 400,
|
||||
margin: 0,
|
||||
color: { dark: '#000000', light: '#ffffff' },
|
||||
errorCorrectionLevel: 'L', // Low error correction = less dots
|
||||
color: { dark: '#000a66', light: '#ffffff' },
|
||||
});
|
||||
|
||||
const safeName = person.name.replace(/\s+/g, '_');
|
||||
const finalFileName = `KLZ_Visitenkarte_${safeName}.pdf`;
|
||||
const rgbPath = path.join(CONFIG.outputDir, `RGB_${finalFileName}`);
|
||||
const finalPath = path.join(CONFIG.outputDir, finalFileName);
|
||||
|
||||
console.log(`- Rendering React-PDF layout (RGB ONLY)...`);
|
||||
const buffer = await renderToBuffer(
|
||||
// 1. STANDARD DIGITAL PDF (RGB, no letterpress mask)
|
||||
console.log(`- Rendering standard digital PDF (RGB ONLY, Blue Variant)...`);
|
||||
const finalFileName = `KLZ_Visitenkarte_${safeName}.pdf`;
|
||||
const tempFileName = `temp_${finalFileName}`;
|
||||
const finalPath = path.join(CONFIG.outputDir, finalFileName);
|
||||
const tempPath = path.join(CONFIG.outputDir, tempFileName);
|
||||
|
||||
let buffer = await renderToBuffer(
|
||||
React.createElement(PDFBusinessCard, {
|
||||
person,
|
||||
qrCodeDataUrl,
|
||||
variant: 'blue',
|
||||
isViaprinto: false,
|
||||
}),
|
||||
);
|
||||
fs.writeFileSync(tempPath, buffer);
|
||||
|
||||
fs.writeFileSync(rgbPath, buffer);
|
||||
|
||||
console.log(`- Outlining fonts via Ghostscript (Keeping RGB)...`);
|
||||
const gsCommand = `gs -dNoOutputFonts -sDEVICE=pdfwrite -o "${finalPath}" "${rgbPath}"`;
|
||||
execSync(gsCommand, { stdio: 'ignore' });
|
||||
|
||||
fs.unlinkSync(rgbPath);
|
||||
|
||||
// Convert fonts to outlines via ghostscript
|
||||
const gsCommand = `gs -dNoOutputFonts -sDEVICE=pdfwrite -o "${finalPath}" "${tempPath}"`;
|
||||
execSync(gsCommand, { stdio: 'inherit' });
|
||||
// fs.unlinkSync(tempPath);
|
||||
console.log(`✓ Final Print File generated (Fonts to Paths, RGB preserved): ${finalFileName}`);
|
||||
|
||||
// Generate White Variant
|
||||
const finalFileNameWhite = `KLZ_Visitenkarte_${safeName}_White.pdf`;
|
||||
const rgbPathWhite = path.join(CONFIG.outputDir, `RGB_${finalFileNameWhite}`);
|
||||
const finalPathWhite = path.join(CONFIG.outputDir, finalFileNameWhite);
|
||||
|
||||
console.log(`- Rendering White Variant...`);
|
||||
const bufferWhite = await renderToBuffer(
|
||||
// 2. VIAPRINTO PDF (Letterpress mask, CMYK)
|
||||
console.log(`- Rendering Viaprinto PDF (Letterpress mask)...`);
|
||||
const tempViaprintoPath = path.join(CONFIG.outputDir, `temp_viaprinto_${safeName}.pdf`);
|
||||
buffer = await renderToBuffer(
|
||||
React.createElement(PDFBusinessCard, {
|
||||
person,
|
||||
qrCodeDataUrl,
|
||||
variant: 'white',
|
||||
variant: 'blue',
|
||||
isViaprinto: true,
|
||||
}),
|
||||
);
|
||||
fs.writeFileSync(tempViaprintoPath, buffer);
|
||||
|
||||
fs.writeFileSync(rgbPathWhite, bufferWhite);
|
||||
// Convert to CMYK for Viaprinto
|
||||
const finalFileNameCMYK = `KLZ_Visitenkarte_${safeName}_Viaprinto_CMYK_91x61mm_3mmBleed.pdf`;
|
||||
const finalPathCMYK = path.join(CONFIG.outputDir, finalFileNameCMYK);
|
||||
|
||||
const gsCommandWhite = `gs -dNoOutputFonts -sDEVICE=pdfwrite -o "${finalPathWhite}" "${rgbPathWhite}"`;
|
||||
execSync(gsCommandWhite, { stdio: 'ignore' });
|
||||
fs.unlinkSync(rgbPathWhite);
|
||||
console.log(`✓ White Variant generated: ${finalFileNameWhite}`);
|
||||
console.log(`- Converting to CMYK for Viaprinto...`);
|
||||
const cmykCommand = `bash "${CONFIG.ghostscriptScript}" "${tempViaprintoPath}" "${finalPathCMYK}"`;
|
||||
try {
|
||||
execSync(cmykCommand, { stdio: 'inherit' });
|
||||
fs.unlinkSync(tempViaprintoPath);
|
||||
console.log(`[INFO] Injecting PANTONE 289 C Spot Color...`);
|
||||
const injectPantoneCommand = `npx tsx scripts/inject-pantone.ts "${finalPathCMYK}"`;
|
||||
execSync(injectPantoneCommand, { stdio: 'inherit' });
|
||||
|
||||
// Generate Black Variant
|
||||
const finalFileNameBlack = `KLZ_Visitenkarte_${safeName}_Black.pdf`;
|
||||
const rgbPathBlack = path.join(CONFIG.outputDir, `RGB_${finalFileNameBlack}`);
|
||||
const finalPathBlack = path.join(CONFIG.outputDir, finalFileNameBlack);
|
||||
console.log(`✓ 5-Color Print File generated: ${finalFileNameCMYK}`);
|
||||
} catch (e) {
|
||||
console.error(`❌ CMYK conversion failed! Is Ghostscript installed?`);
|
||||
}
|
||||
|
||||
console.log(`- Rendering Black Variant...`);
|
||||
const bufferBlack = await renderToBuffer(
|
||||
// 2.5 SAXOPRINT PDF (Pure CMYK, no mask, no Pantone)
|
||||
console.log(`- Rendering Saxoprint PDF (Pure CMYK)...`);
|
||||
const tempSaxoprintPath = path.join(CONFIG.outputDir, `temp_saxoprint_${safeName}.pdf`);
|
||||
buffer = await renderToBuffer(
|
||||
React.createElement(PDFBusinessCard, {
|
||||
person,
|
||||
qrCodeDataUrl,
|
||||
variant: 'black',
|
||||
variant: 'blue',
|
||||
}),
|
||||
);
|
||||
fs.writeFileSync(tempSaxoprintPath, buffer);
|
||||
|
||||
fs.writeFileSync(rgbPathBlack, bufferBlack);
|
||||
const finalFileNameSaxoCMYK = `KLZ_Visitenkarte_${safeName}_Saxoprint_CMYK_91x61mm_3mmBleed.pdf`;
|
||||
const finalPathSaxoCMYK = path.join(CONFIG.outputDir, finalFileNameSaxoCMYK);
|
||||
|
||||
const gsCommandBlack = `gs -dNoOutputFonts -sDEVICE=pdfwrite -o "${finalPathBlack}" "${rgbPathBlack}"`;
|
||||
execSync(gsCommandBlack, { stdio: 'ignore' });
|
||||
fs.unlinkSync(rgbPathBlack);
|
||||
console.log(`✓ Black Variant generated: ${finalFileNameBlack}`);
|
||||
console.log(`- Converting to CMYK for Saxoprint...`);
|
||||
const cmykSaxoCommand = `bash "${CONFIG.ghostscriptScript}" "${tempSaxoprintPath}" "${finalPathSaxoCMYK}"`;
|
||||
try {
|
||||
execSync(cmykSaxoCommand, { stdio: 'inherit' });
|
||||
fs.unlinkSync(tempSaxoprintPath);
|
||||
console.log(`✓ 4-Color CMYK Print File generated: ${finalFileNameSaxoCMYK}`);
|
||||
} catch (e) {
|
||||
console.error(`❌ CMYK conversion failed! Is Ghostscript installed?`);
|
||||
}
|
||||
|
||||
// 3. NO TRUCK PDF (RGB)
|
||||
console.log(`- Rendering No Truck Variant (RGB)...`);
|
||||
const noTruckFileName = `KLZ_Visitenkarte_${safeName}_NoTruck.pdf`;
|
||||
const tempNoTruckPath = path.join(CONFIG.outputDir, `temp_${noTruckFileName}`);
|
||||
const finalNoTruckPath = path.join(CONFIG.outputDir, noTruckFileName);
|
||||
|
||||
buffer = await renderToBuffer(
|
||||
React.createElement(PDFBusinessCard, {
|
||||
person,
|
||||
qrCodeDataUrl,
|
||||
variant: 'blue',
|
||||
isViaprinto: false,
|
||||
withTruck: false,
|
||||
}),
|
||||
);
|
||||
fs.writeFileSync(tempNoTruckPath, buffer);
|
||||
const gsNoTruckCommand = `gs -dNoOutputFonts -sDEVICE=pdfwrite -o "${finalNoTruckPath}" "${tempNoTruckPath}"`;
|
||||
execSync(gsNoTruckCommand, { stdio: 'inherit' });
|
||||
fs.unlinkSync(tempNoTruckPath);
|
||||
// 4. CROP MARKS PDF (RGB)
|
||||
console.log(`- Rendering Crop Marks Variant (RGB)...`);
|
||||
const cropMarksFileName = `KLZ_Visitenkarte_${safeName}_CropMarks.pdf`;
|
||||
const tempCropMarksPath = path.join(CONFIG.outputDir, `temp_${cropMarksFileName}`);
|
||||
const finalCropMarksPath = path.join(CONFIG.outputDir, cropMarksFileName);
|
||||
|
||||
buffer = await renderToBuffer(
|
||||
React.createElement(PDFBusinessCard, {
|
||||
person,
|
||||
qrCodeDataUrl,
|
||||
variant: 'blue',
|
||||
isViaprinto: false,
|
||||
withCropMarks: true,
|
||||
}),
|
||||
);
|
||||
fs.writeFileSync(tempCropMarksPath, buffer);
|
||||
const gsCropMarksCommand = `gs -dNoOutputFonts -sDEVICE=pdfwrite -o "${finalCropMarksPath}" "${tempCropMarksPath}"`;
|
||||
execSync(gsCropMarksCommand, { stdio: 'inherit' });
|
||||
fs.unlinkSync(tempCropMarksPath);
|
||||
console.log(`✓ Crop Marks Print File generated: ${cropMarksFileName}`);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
93
scripts/inject-letterpress.ts
Normal file
93
scripts/inject-letterpress.ts
Normal file
@@ -0,0 +1,93 @@
|
||||
import { PDFDocument, PDFName, PDFString, PDFNumber, PDFDict, PDFArray } from 'pdf-lib';
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
|
||||
async function run() {
|
||||
const filePaths = [
|
||||
'KLZ_Visitenkarte_Michael_Bodemer_Viaprinto_CMYK.pdf',
|
||||
'KLZ_Visitenkarte_Klaus_Mintel_Viaprinto_CMYK.pdf',
|
||||
'KLZ_Visitenkarte_Johannes_Gleich_Viaprinto_CMYK.pdf',
|
||||
];
|
||||
|
||||
const outputDir = path.join(process.cwd(), '.klz-print-engine-output');
|
||||
|
||||
for (const fileName of filePaths) {
|
||||
const filePath = path.join(outputDir, fileName);
|
||||
if (!fs.existsSync(filePath)) continue;
|
||||
|
||||
console.log(`Injecting Letterpress spot color into: ${fileName}`);
|
||||
const pdfBytes = fs.readFileSync(filePath);
|
||||
const pdfDoc = await PDFDocument.load(pdfBytes);
|
||||
|
||||
// Create the /Separation /letterpress /DeviceCMYK color space
|
||||
// 0 1 0 0 in CMYK is Magenta.
|
||||
const tintTransform = pdfDoc.context.obj({
|
||||
FunctionType: 4,
|
||||
Domain: [0, 1],
|
||||
Range: [0, 1, 0, 1, 0, 1, 0, 1],
|
||||
Length: 26,
|
||||
});
|
||||
|
||||
// Simple postscript function: { 0 1 0 0 } - just output magenta for preview
|
||||
const stream = pdfDoc.context.stream('{ 0 1 0 0 }', {
|
||||
FunctionType: 4,
|
||||
Domain: [0, 1],
|
||||
Range: [0, 1, 0, 1, 0, 1, 0, 1],
|
||||
});
|
||||
|
||||
const letterpressCS = pdfDoc.context.obj([
|
||||
PDFName.of('Separation'),
|
||||
PDFName.of('letterpress'),
|
||||
PDFName.of('DeviceCMYK'),
|
||||
stream,
|
||||
]);
|
||||
const csRef = pdfDoc.context.register(letterpressCS);
|
||||
|
||||
const extGState = pdfDoc.context.obj({
|
||||
Type: 'ExtGState',
|
||||
OP: true,
|
||||
op: true,
|
||||
OPM: 1,
|
||||
});
|
||||
const gsRef = pdfDoc.context.register(extGState);
|
||||
|
||||
// We inject the color space into all pages
|
||||
const pages = pdfDoc.getPages();
|
||||
for (const page of pages) {
|
||||
let resources = page.node.Resources();
|
||||
if (!resources) {
|
||||
resources = pdfDoc.context.obj({});
|
||||
page.node.set(PDFName.of('Resources'), resources);
|
||||
}
|
||||
|
||||
// ColorSpace
|
||||
let colorSpaces = resources.get(PDFName.of('ColorSpace')) as PDFDict;
|
||||
if (!colorSpaces) {
|
||||
colorSpaces = pdfDoc.context.obj({});
|
||||
resources.set(PDFName.of('ColorSpace'), colorSpaces);
|
||||
}
|
||||
colorSpaces.set(PDFName.of('CSLetterpress'), csRef);
|
||||
|
||||
// ExtGState
|
||||
let extGStates = resources.get(PDFName.of('ExtGState')) as PDFDict;
|
||||
if (!extGStates) {
|
||||
extGStates = pdfDoc.context.obj({});
|
||||
resources.set(PDFName.of('ExtGState'), extGStates);
|
||||
}
|
||||
extGStates.set(PDFName.of('GSLetterpress'), gsRef);
|
||||
}
|
||||
|
||||
// Now we need to search the Content Streams for #ff00fe.
|
||||
// In CMYK without ICC profiling, Ghostscript maps #ff00fe (RGB 1 0 0.996)
|
||||
// Actually, Ghostscript strips RGB completely. It maps it to something close to 0 1 0 0 k.
|
||||
// Instead of messing with Ghostscript output, I'll just write a new file!
|
||||
|
||||
// Actually, I don't need to do stream hacking if I just generate a separate PDF
|
||||
// from React-PDF that contains ONLY the letterpress elements,
|
||||
// then use Ghostscript to merge them!
|
||||
|
||||
// Since stream manipulation is highly brittle, let's keep it simple.
|
||||
}
|
||||
}
|
||||
|
||||
run().catch(console.error);
|
||||
105
scripts/inject-pantone.ts
Normal file
105
scripts/inject-pantone.ts
Normal file
@@ -0,0 +1,105 @@
|
||||
import { PDFDocument, PDFName, decodePDFRawStream, PDFRawStream } from 'pdf-lib';
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
|
||||
async function run() {
|
||||
const inputPath = process.argv[2];
|
||||
const outputPath = process.argv[3] || inputPath;
|
||||
|
||||
if (!inputPath || !fs.existsSync(inputPath)) {
|
||||
console.error(`[ERROR] Input file not found: ${inputPath}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
console.log(`[INFO] Injecting PANTONE 289 C into: ${path.basename(inputPath)}`);
|
||||
const pdfBytes = fs.readFileSync(inputPath);
|
||||
const pdfDoc = await PDFDocument.load(pdfBytes);
|
||||
|
||||
// CMYK tint transform for PANTONE 289 C.
|
||||
// Approximation: 100C, 64M, 0Y, 60K (1.0 0.64 0.0 0.60)
|
||||
// The function maps an input x (0 to 1) to CMYK: x*1.0, x*0.64, x*0.0, x*0.60
|
||||
const tintTransform = pdfDoc.context.obj({
|
||||
FunctionType: 2,
|
||||
Domain: [0, 1],
|
||||
C0: [0, 0, 0, 0],
|
||||
C1: [1.0, 0.64, 0.0, 0.6],
|
||||
N: 1,
|
||||
});
|
||||
|
||||
// Create the /Separation color space array
|
||||
const pantoneCS = pdfDoc.context.obj([
|
||||
PDFName.of('Separation'),
|
||||
PDFName.of('PANTONE#20289#20C'),
|
||||
PDFName.of('DeviceCMYK'),
|
||||
tintTransform,
|
||||
]);
|
||||
const csRef = pdfDoc.context.register(pantoneCS);
|
||||
|
||||
const pages = pdfDoc.getPages();
|
||||
for (const page of pages) {
|
||||
let resources = page.node.Resources();
|
||||
if (!resources) {
|
||||
resources = pdfDoc.context.obj({});
|
||||
page.node.set(PDFName.of('Resources'), resources);
|
||||
}
|
||||
|
||||
// Ensure ColorSpace dict exists
|
||||
let colorSpaces = resources.get(PDFName.of('ColorSpace')) as any;
|
||||
if (!colorSpaces) {
|
||||
colorSpaces = pdfDoc.context.obj({});
|
||||
resources.set(PDFName.of('ColorSpace'), colorSpaces);
|
||||
}
|
||||
// Register the PANTONE color space as /CSPantone in the page's resources
|
||||
colorSpaces.set(PDFName.of('CSPantone'), csRef);
|
||||
|
||||
// Read all content streams
|
||||
const contents = page.node.Contents();
|
||||
let streams: any[] = [];
|
||||
if (contents) {
|
||||
const resolvedContents = pdfDoc.context.lookup(contents);
|
||||
if (resolvedContents instanceof PDFRawStream) {
|
||||
streams = [resolvedContents];
|
||||
} else if (resolvedContents.constructor.name === 'PDFArray') {
|
||||
const arr = resolvedContents as any;
|
||||
for (let j = 0; j < arr.size(); j++) {
|
||||
streams.push(pdfDoc.context.lookup(arr.get(j)));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let newStr = '';
|
||||
for (let i = 0; i < streams.length; i++) {
|
||||
const stream = streams[i];
|
||||
if (stream instanceof PDFRawStream) {
|
||||
const decoded = decodePDFRawStream(stream).decode();
|
||||
const str = Buffer.from(decoded).toString('utf-8');
|
||||
newStr += str + '\n';
|
||||
}
|
||||
}
|
||||
|
||||
// The CMYK values for the KLZ Blue created by Ghostscript from #000a66
|
||||
// is '1 0.98 0.224 0.298'. We use a slightly dynamic regex to catch small precision diffs.
|
||||
const dynamicRegexK = /1 0\.98[0-9]* 0\.224[0-9]* 0\.298[0-9]* k/g;
|
||||
const dynamicRegexStrokeK = /1 0\.98[0-9]* 0\.224[0-9]* 0\.298[0-9]* K/g;
|
||||
|
||||
let modified = false;
|
||||
if (dynamicRegexK.test(newStr) || dynamicRegexStrokeK.test(newStr)) {
|
||||
newStr = newStr.replace(dynamicRegexK, '/CSPantone cs 1 scn');
|
||||
newStr = newStr.replace(dynamicRegexStrokeK, '/CSPantone CS 1 SCN');
|
||||
modified = true;
|
||||
}
|
||||
|
||||
if (modified) {
|
||||
const newStream = pdfDoc.context.flateStream(newStr);
|
||||
const newRef = pdfDoc.context.register(newStream);
|
||||
page.node.set(PDFName.of('Contents'), newRef);
|
||||
console.log(`[INFO] Spot color injected into page ${pages.indexOf(page) + 1}.`);
|
||||
}
|
||||
}
|
||||
|
||||
const finalPdfBytes = await pdfDoc.save();
|
||||
fs.writeFileSync(outputPath, finalPdfBytes);
|
||||
console.log(`[SUCCESS] Saved 5-color PDF to ${outputPath}`);
|
||||
}
|
||||
|
||||
run().catch(console.error);
|
||||
64
scripts/svg-to-react-pdf.ts
Normal file
64
scripts/svg-to-react-pdf.ts
Normal file
@@ -0,0 +1,64 @@
|
||||
import fs from 'fs';
|
||||
|
||||
const svgFile = process.argv[2];
|
||||
const outFile = process.argv[3];
|
||||
|
||||
let content = fs.readFileSync(svgFile, 'utf8');
|
||||
|
||||
content = content.replace(/<\?xml.*\?>/g, '');
|
||||
content = content.replace(/<!DOCTYPE.*>/g, '');
|
||||
content = content.replace(/xmlns:xlink="[^"]+"/g, '');
|
||||
content = content.replace(/xmlns:serif="[^"]+"/g, '');
|
||||
content = content.replace(/xml:space="preserve"/g, '');
|
||||
content = content.replace(/xmlns="[^"]+"/g, '');
|
||||
content = content.replace(/version="[^"]+"/g, '');
|
||||
|
||||
// Convert attributes to camelCase
|
||||
content = content.replace(/fill-rule/g, 'fillRule');
|
||||
content = content.replace(/clip-rule/g, 'clipRule');
|
||||
content = content.replace(/stroke-linejoin/g, 'strokeLinejoin');
|
||||
content = content.replace(/stroke-miterlimit/g, 'strokeMiterlimit');
|
||||
|
||||
// Convert style="fill:#xxxx;fill-rule:nonzero;" to fill="#xxxx" fillRule="nonzero"
|
||||
content = content.replace(/style="([^"]+)"/g, (match, styleStr) => {
|
||||
const parts = styleStr.split(';').filter(Boolean);
|
||||
let attrs = [];
|
||||
for (const part of parts) {
|
||||
const [key, val] = part.split(':');
|
||||
if (key === 'fill') attrs.push(`fill="${val}"`);
|
||||
if (key === 'fillRule' || key === 'fill-rule') attrs.push(`fillRule="${val}"`);
|
||||
if (key === 'clipRule' || key === 'clip-rule') attrs.push(`clipRule="${val}"`);
|
||||
if (key === 'strokeLinejoin' || key === 'stroke-linejoin')
|
||||
attrs.push(`strokeLinejoin="${val}"`);
|
||||
if (key === 'strokeMiterlimit' || key === 'stroke-miterlimit')
|
||||
attrs.push(`strokeMiterlimit="${val}"`);
|
||||
}
|
||||
return attrs.join(' ');
|
||||
});
|
||||
|
||||
// React components
|
||||
content = content.replace(/<svg/g, '<Svg');
|
||||
content = content.replace(/<\/svg>/g, '</Svg>');
|
||||
content = content.replace(/<g/g, '<G');
|
||||
content = content.replace(/<\/g>/g, '</G>');
|
||||
content = content.replace(/<path/g, '<Path');
|
||||
content = content.replace(/<\/path>/g, '</Path>');
|
||||
|
||||
// In React PDF, width/height on Svg can be removed and controlled via style.
|
||||
content = content.replace(/width="100%" height="100%" /, '');
|
||||
|
||||
const final = `
|
||||
import React from 'react';
|
||||
import { Svg, G, Path } from '@react-pdf/renderer';
|
||||
|
||||
export const TruckBlueprint = ({ style, opacity = 1 }: { style?: any, opacity?: number }) => (
|
||||
<Svg viewBox="0 0 1615 974" style={[style, { opacity }]}>
|
||||
<G fillRule="evenodd" clipRule="evenodd" strokeLinejoin="round" strokeMiterlimit="2">
|
||||
${content.match(/<G[\s\S]*<\/G>/)?.[0] || content}
|
||||
</G>
|
||||
</Svg>
|
||||
);
|
||||
`;
|
||||
|
||||
fs.writeFileSync(outFile, final);
|
||||
console.log('Done!');
|
||||
Reference in New Issue
Block a user