Skip to content

Commit

Permalink
900: refactor pdf
Browse files Browse the repository at this point in the history
  • Loading branch information
sarahsporck committed May 23, 2023
1 parent c9d6ba1 commit 6348adf
Show file tree
Hide file tree
Showing 14 changed files with 222 additions and 169 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,7 @@ const useCardGenerator = (region: Region) => {
})
: []

const pdfDataUri = await generatePdf(dynamicCodes, staticCodes, region, projectConfig.pdf)
const pdfDataUri = await generatePdf(dynamicCodes, staticCodes, cardBlueprints, region, projectConfig.pdf)

const codes = [...dynamicCodes, ...staticCodes]
await createCards(client, codes, region)
Expand Down
2 changes: 1 addition & 1 deletion administration/src/cards/CardBlueprint.ts
Original file line number Diff line number Diff line change
Expand Up @@ -90,7 +90,7 @@ export class CardBlueprint implements JSONCardBlueprint {
const extensionsMessage: PartialMessage<CardExtensions> = {}

this.extensions.forEach(extension => {
if (extension.state === null) {
if (extension.state === null || !extension.setProtobufData) {
// We allow to skip invalid extensions to enable computing the protobuf size.
return
}
Expand Down
135 changes: 22 additions & 113 deletions administration/src/cards/PdfFactory.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
import { PDFDocument, PDFFont, PDFPage, StandardFonts } from 'pdf-lib'
import { PDFDocument, PDFPage, StandardFonts } from 'pdf-lib'

import { CardInfo, DynamicActivationCode, QrCode, StaticVerificationCode } from '../generated/card_pb'
import { DynamicActivationCode, QrCode, StaticVerificationCode } from '../generated/card_pb'
import { Region } from '../generated/graphql'
import { PdfConfig } from '../project-configs/getProjectConfig'
import { drawQRCode } from '../util/qrcode'
import CardBlueprint from './CardBlueprint'

export class PDFError extends Error {
constructor(message: string) {
Expand All @@ -12,135 +12,42 @@ export class PDFError extends Error {
}
}

const dynamicQRCodeSize = 84 // mm
const dynamicQRCodeX = 108 // mm
const dynamicQRCodeY = 73 // mm

const dynamicDetailWidth = 84 // mm
const dynamicDetailX = 108 // mm
const dynamicDetailY = 170 // mm
const dynamicDetailFontSize = 10

const staticBackQRCodeSize = 48 // mm
const staticBackQRCodeX = 51 // mm
const staticBackQRCodeY = 228 // mm

const staticFrontQRCodeSize = 23 // mm
const staticFrontQRCodeX = 156 // mm
const staticFrontQRCodeY = 249 // mm

const staticDetailWidth = 46 // mm
const staticDetailX = 107 // mm
const staticDetailY = 248 // mm
const staticDetailFontSize = 8

function mmToPt(mm: number) {
return (mm / 25.4) * 72
}

type DynamicPdfQrCode = {
value: DynamicActivationCode
case: 'dynamicActivationCode'
}

type StaticPdfQrCode = {
value: StaticVerificationCode
case: 'staticVerificationCode'
}

type PdfQrCode = DynamicPdfQrCode | StaticPdfQrCode

async function fillContentAreas(
doc: PDFDocument,
templatePage: PDFPage,
dynamicCode: DynamicPdfQrCode,
staticCode: StaticPdfQrCode | null,
dynamicCode: Extract<QrCode['qrCode'], { case: 'dynamicActivationCode' }>,
staticCode: Extract<QrCode['qrCode'], { case: 'staticVerificationCode' }> | null,
region: Region,
cardBlueprint: CardBlueprint,
pdfConfig: PdfConfig
) {
const helveticaFont = await doc.embedFont(StandardFonts.Helvetica)

// Dynamic QR code
fillCodeArea(dynamicCode, dynamicQRCodeX, dynamicQRCodeY, dynamicQRCodeSize, templatePage)
fillDetailsArea(
dynamicCode.value.info!,
region,
dynamicDetailX,
dynamicDetailY,
dynamicDetailWidth,
helveticaFont,
dynamicDetailFontSize,
templatePage,
false,
pdfConfig
pdfConfig.elements?.dynamicQrCodes.forEach(pdfQrCodeElementRenderer =>
pdfQrCodeElementRenderer({ page: templatePage, qrCode: dynamicCode })
)

// Static QR code
if (staticCode) {
// Back
fillCodeArea(staticCode, staticBackQRCodeX, staticBackQRCodeY, staticBackQRCodeSize, templatePage)

// Front
fillCodeArea(staticCode, staticFrontQRCodeX, staticFrontQRCodeY, staticFrontQRCodeSize, templatePage)
fillDetailsArea(
staticCode.value.info!,
region,
staticDetailX,
staticDetailY,
staticDetailWidth,
helveticaFont,
staticDetailFontSize,
templatePage,
true,
pdfConfig
pdfConfig.elements?.staticQrCodes?.forEach(pdfQrCodeElementRenderer =>
pdfQrCodeElementRenderer({ page: templatePage, qrCode: staticCode })
)
}
}

function fillDetailsArea(
info: CardInfo,
region: Region,
x: number,
y: number,
width: number,
font: PDFFont,
fontSize: number,
page: PDFPage,
shorten: boolean,
pdfConfig: PdfConfig
) {
const detailXPdf = mmToPt(x)
const detailYPdf = page.getSize().height - mmToPt(y)

const lineHeight = mmToPt(5)

const text = pdfConfig.infoToDetails(info, region, shorten)
page.drawText(text, {
font,
x: detailXPdf,
y: detailYPdf - lineHeight,
maxWidth: mmToPt(width),
wordBreaks: text.split('').filter(c => !'\n\f\r\u000B'.includes(c)), // Split on every character
lineHeight,
size: fontSize,
})
}

function fillCodeArea(qrCode: PdfQrCode, x: number, y: number, size: number, page: PDFPage) {
const qrCodeSizePdf = mmToPt(size)
const qrCodeXPdf = mmToPt(x)
const qrCodeYPdf = page.getSize().height - qrCodeSizePdf - mmToPt(y)

const qrCodeContent = new QrCode({
qrCode: qrCode,
}).toBinary()

drawQRCode(qrCodeContent, qrCodeXPdf, qrCodeYPdf, qrCodeSizePdf, page, false)
pdfConfig.elements?.details.forEach(pdfDetailElementRenderer =>
pdfDetailElementRenderer({
page: templatePage,
font: helveticaFont,
info: dynamicCode.value.info!,
region: region,
cardBlueprint,
})
)
}

export async function generatePdf(
dynamicCodes: DynamicActivationCode[],
staticCodes: StaticVerificationCode[],
cardBlueprints: CardBlueprint[],
region: Region,
pdfConfig: PdfConfig
) {
Expand All @@ -159,6 +66,7 @@ export async function generatePdf(
for (let k = 0; k < dynamicCodes.length; k++) {
const dynamicCode = dynamicCodes[k]
const staticCode = staticCodes?.at(k)
const cardBlueprint = cardBlueprints[k]

const [templatePage] = templateDocument ? await doc.copyPages(templateDocument, [0]) : [null]

Expand All @@ -178,6 +86,7 @@ export async function generatePdf(
}
: null,
region,
cardBlueprint,
pdfConfig
)
}
Expand Down
9 changes: 8 additions & 1 deletion administration/src/cards/extensions/extensions.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,13 @@ import NuernbergPassIdExtension from './NuernbergPassIdExtension'
import NuernbergPassNumberExtension from './NuernbergPassNumberExtension'
import RegionExtension from './RegionExtension'

export const findExtension = <E extends ExtensionClass>(
array: ExtensionInstance[],
extension: E
): InstanceType<E> | undefined => {
return array.find(e => e instanceof extension) as InstanceType<E> | undefined
}

export interface JSONExtension<T> {
name: string
state: T | null
Expand All @@ -20,7 +27,7 @@ export abstract class Extension<T, R> implements JSONExtension<T> {
abstract isValid(): boolean
abstract createForm(onChange: () => void): ReactElement | null
abstract causesInfiniteLifetime(): boolean
abstract setProtobufData(message: PartialMessage<CardExtensions>): void
setProtobufData?(message: PartialMessage<CardExtensions>): void
abstract fromString(state: string): void
abstract toString(): string
}
Expand Down
48 changes: 48 additions & 0 deletions administration/src/cards/pdf/PdfDetailElement.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
import { PDFFont, PDFPage } from 'pdf-lib'

import { CardInfo } from '../../generated/card_pb'
import { Region } from '../../generated/graphql'
import CardBlueprint from '../CardBlueprint'
import { Coordinates, PdfElement, PdfElementRenderer, mmToPt } from './PdfElements'

export type InfoParams = {
info: CardInfo
region: Region
cardBlueprint: CardBlueprint
}

type PdfDetailElementProps = {
width: number
fontSize: number
infoToDetails: (info: InfoParams) => string
} & Coordinates

type PdfDetailElementRendererProps = {
page: PDFPage
font: PDFFont
info: CardInfo
region: Region
cardBlueprint: CardBlueprint
}

export type PdfDetailElementRenderer = PdfElementRenderer<PdfDetailElementRendererProps>

const PdfDetailElement: PdfElement<PdfDetailElementProps, PdfDetailElementRendererProps> =
({ width, x, y, fontSize, infoToDetails }) =>
({ page, font, info, region, cardBlueprint }) => {
const text = infoToDetails({ info, region, cardBlueprint })

const lineHeight = mmToPt(5)

page.drawText(text, {
font,
x: mmToPt(x),
y: page.getSize().height - mmToPt(y) - lineHeight,
maxWidth: mmToPt(width),
wordBreaks: text.split('').filter(c => !'\n\f\r\u000B'.includes(c)), // Split on every character
lineHeight,
size: fontSize,
})
}

export default PdfDetailElement
13 changes: 13 additions & 0 deletions administration/src/cards/pdf/PdfElements.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
export type Coordinates = {
x: number
y: number
}

export function mmToPt(mm: number) {
return (mm / 25.4) * 72
}

export type PdfElementRenderer<T extends Record<string, any>> = (info: T) => void
export type PdfElement<P extends Record<string, any>, T extends Record<string, any>> = (
options: P
) => PdfElementRenderer<T>
34 changes: 34 additions & 0 deletions administration/src/cards/pdf/PdfQrCodeElement.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
import { PDFPage } from 'pdf-lib'

import { QrCode } from '../../generated/card_pb'
import { drawQRCode } from '../../util/qrcode'
import { Coordinates, PdfElement, PdfElementRenderer, mmToPt } from './PdfElements'

type PdfQrCode = Extract<QrCode['qrCode'], { case: 'staticVerificationCode' | 'dynamicActivationCode' }>

type PdfQrCodeElementProps = {
size: number
} & Coordinates

type PdfQrCodeElementRendererProps = {
page: PDFPage
qrCode: PdfQrCode
}

export type PdfQrCodeElementRenderer = PdfElementRenderer<PdfQrCodeElementRendererProps>

const PdfQrCodeElement: PdfElement<PdfQrCodeElementProps, PdfQrCodeElementRendererProps> =
({ size, x, y }) =>
({ page, qrCode }) => {
const qrCodeSizePdf = mmToPt(size)
const qrCodeXPdf = mmToPt(x)
const qrCodeYPdf = page.getSize().height - qrCodeSizePdf - mmToPt(y)

const qrCodeContent = new QrCode({
qrCode: qrCode,
}).toBinary()

drawQRCode(qrCodeContent, qrCodeXPdf, qrCodeYPdf, qrCodeSizePdf, page, false)
}

export default PdfQrCodeElement
28 changes: 2 additions & 26 deletions administration/src/project-configs/bayern/config.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,8 @@
import { format } from 'date-fns'

import BavariaCardTypeExtension from '../../cards/extensions/BavariaCardTypeExtension'
import RegionExtension from '../../cards/extensions/RegionExtension'
import { daysSinceEpochToDate } from '../../cards/validityPeriod'
import { BavariaCardType } from '../../generated/card_pb'
import { ProjectConfig } from '../getProjectConfig'
import { DataPrivacyBaseText, dataPrivacyBaseHeadline } from './dataPrivacyBase'
// @ts-ignore
import pdfTemplate from './pdf-template.pdf'
import pdfConfiguration from './pdf'

const config: ProjectConfig = {
name: 'Ehrenamtskarte Bayern',
Expand All @@ -24,26 +19,7 @@ const config: ProjectConfig = {
dataPrivacyHeadline: dataPrivacyBaseHeadline,
dataPrivacyContent: DataPrivacyBaseText,
timezone: 'Europe/Berlin',
pdf: {
title: 'Ehrenamtskarten',
templatePath: pdfTemplate,
issuer: 'Bayerische Staatsministerium für Arbeit und Soziales, Familie und Integration',
infoToDetails: (info, region, shorten) => {
const expirationDay = info.expirationDay ?? 0
const expirationDate =
expirationDay > 0 ? format(daysSinceEpochToDate(expirationDay), 'dd.MM.yyyy') : 'unbegrenzt'

const cardType = info.extensions?.extensionBavariaCardType?.cardType
return `${info.fullName}
Kartentyp: ${cardType === BavariaCardType.STANDARD ? 'Blau' : 'Gold'}
Gültig bis: ${expirationDate}
${
shorten
? `Aussteller: ${region.prefix} ${region.name}`
: `Ausgestellt am ${format(new Date(), 'dd.MM.yyyy')} \nvon ${region.prefix} ${region.name}`
}`
},
},
pdf: pdfConfiguration,
}

export default config
33 changes: 33 additions & 0 deletions administration/src/project-configs/bayern/pdf.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
import { format } from 'date-fns'

import PdfDetailElement, { InfoParams } from '../../cards/pdf/PdfDetailElement'
import PdfQrCodeElement from '../../cards/pdf/PdfQrCodeElement'
import { daysSinceEpochToDate } from '../../cards/validityPeriod'
import { BavariaCardType } from '../../generated/card_pb'
import { PdfConfig } from '../getProjectConfig'
// @ts-ignore
import pdfTemplate from './pdf-template.pdf'

const renderPdfInfo = ({ info, region }: InfoParams) => {
const expirationDay = info.expirationDay ?? 0
const expirationDate = expirationDay > 0 ? format(daysSinceEpochToDate(expirationDay), 'dd.MM.yyyy') : 'unbegrenzt'

const cardType = info.extensions?.extensionBavariaCardType?.cardType
return `${info.fullName}
Kartentyp: ${cardType === BavariaCardType.STANDARD ? 'Blau' : 'Gold'}
Gültig bis: ${expirationDate}
Ausgestellt am ${format(new Date(), 'dd.MM.yyyy')}
von ${region.prefix} ${region.name}`
}

const pdfConfiguration: PdfConfig = {
title: 'Ehrenamtskarten',
templatePath: pdfTemplate,
issuer: 'Bayerische Staatsministerium für Arbeit und Soziales, Familie und Integration',
elements: {
dynamicQrCodes: [PdfQrCodeElement({ x: 108, y: 73, size: 84 })],
details: [PdfDetailElement({ x: 108, y: 170, width: 84, fontSize: 10, infoToDetails: renderPdfInfo })],
},
}

export default pdfConfiguration
Loading

0 comments on commit 6348adf

Please sign in to comment.