feat: implement STCW certificate template conversion to canvas blocks

This commit is contained in:
nati
2026-09-08 06:36:08 +00:00
parent e807839d74
commit 41067fb07f
3 changed files with 328 additions and 0 deletions

View File

@@ -0,0 +1,222 @@
import type { TemplateFieldPlacement } from '@ema-platform/api';
/**
* One-time, one-way conversion of the hand-written STCW certificate template
* (`SEAFARER_CERTIFICATE_TEMPLATE` on the server) into an equivalent canvas
* layout.
*
* This is not a general HTML-to-blocks parser — none exists, and none is
* feasible: the canvas only ever emits absolute-positioned text/image blocks
* (see `layout-compiler.ts`), so a `<table>`, a `{{#if}}` fallback chain, or a
* border/outline rule has no block equivalent to convert to. What this does
* is reproduce *this one template's* known field positions as blocks, by
* hand, from its own `mm`-based CSS — an approximation good enough to keep
* editing visually from here, not a lossless round-trip. Once converted, the
* canvas becomes the new source of truth for the draft, same as any
* canvas-authored design; the original HTML is gone unless the draft is
* reverted before saving.
*
* What is lost, deliberately, by this conversion:
* - The bordered/outlined sheet frame, the `facts` table's row lines, and
* every font/spacing rule in the template's `<style>` block — the canvas
* has no border or table primitive, only positioned text and images.
* - The rank/proficiency fallback chain
* (`rank ?? rankEngine ?? proficiencyDeck ?? proficiencyEngine ?? proficiencyOther`)
* collapses to a single field (`form.certificate.rank`) — a CoP whose
* applicant used one of the other fields will need that block's variable
* changed by hand after conversion.
* - The "CERTIFICATE OF {COMPETENCY|PROFICIENCY}" title conditional becomes a
* fixed literal ("CERTIFICATE OF COMPETENCY / PROFICIENCY") — the same
* template is seeded for both licence types, and a canvas block cannot
* branch on `licenseTypeName` the way the Handlebars `{{#if}}` did.
* - Amharic/English authority header text becomes two literal text blocks
* rather than being drawn from the template source, so it can be edited or
* removed like any other block.
*/
/** Guard: only offer the conversion for a draft that is actually this template, not any hand-written source. */
export function looksLikeStcwTemplate(hbsSource: string): boolean {
return (
hbsSource.includes('has met the STCW requirements') &&
hbsSource.includes('table class="facts"') &&
hbsSource.includes('{{holderName}}')
);
}
const SHEET_WIDTH_MM = 297;
const SHEET_HEIGHT_MM = 210;
function xPct(mm: number): number {
return Math.round((mm / SHEET_WIDTH_MM) * 1000) / 10;
}
function yPct(mm: number): number {
return Math.round((mm / SHEET_HEIGHT_MM) * 1000) / 10;
}
let counter = 0;
/** Stable-enough ids for a batch created in one call — matches the `blk_` scheme `useTemplateDraft` uses elsewhere. */
function blockId(): string {
counter += 1;
return `blk_stcw${Date.now().toString(36)}${counter}`;
}
function text(
variable: string | null,
content: string | undefined,
x: number,
y: number,
width: number,
opts: Partial<TemplateFieldPlacement> = {},
): TemplateFieldPlacement {
return {
id: blockId(),
variable,
text: content,
type: 'text',
xPct: x,
yPct: y,
widthPct: width,
fontSize: 14,
fontWeight: 'normal',
fontStyle: 'normal',
align: 'left',
color: '#111111',
...opts,
};
}
function image(
variable: string,
x: number,
y: number,
width: number,
): TemplateFieldPlacement {
return { id: blockId(), variable, type: 'image', xPct: x, yPct: y, widthPct: width };
}
/**
* Produces the block layout. Takes `licenseTypeName` only to pick the fixed
* title text (see the class doc above) — everything else is positional.
*/
export function convertStcwTemplateToBlocks(licenseTypeName: string): TemplateFieldPlacement[] {
const isProficiency = licenseTypeName.toLowerCase().includes('proficiency');
const blocks: TemplateFieldPlacement[] = [];
// Header: authority name, centered-ish block near the top. The template's
// flex-centered header has no block equivalent, so this is placed by eye.
blocks.push(
text(null, 'የኢትዮጵያ ማሪታይም ባለሥልጣን', 30, 6, 40, {
align: 'center',
fontSize: 13,
fontWeight: 'bold',
}),
text(null, 'ETHIOPIAN MARITIME AUTHORITY', 30, 9.5, 40, {
align: 'center',
fontSize: 15,
fontWeight: 'bold',
color: '#0b3d6b',
}),
text(null, 'Federal Democratic Republic of Ethiopia', 30, 13, 40, {
align: 'center',
fontSize: 9,
color: '#5b6b7f',
}),
image('logo', 8, 6, 15),
);
// Title
blocks.push(
text(
null,
`CERTIFICATE OF ${isProficiency ? 'PROFICIENCY' : 'COMPETENCY'}`,
15,
21,
70,
{ align: 'center', fontSize: 20, fontWeight: 'bold', color: '#0b3d6b' },
),
text('licenseTypeName', undefined, 15, 25.5, 70, {
align: 'center',
fontSize: 12,
fontWeight: 'bold',
color: '#c9a227',
}),
);
// Holder
blocks.push(
text(null, 'This is to certify that', 15, 30, 70, { align: 'center', fontSize: 10, color: '#34495e' }),
image('holderPhoto', 40, 34, 12),
text('holderName', undefined, 15, 42, 70, { align: 'center', fontSize: 17, fontWeight: 'bold' }),
);
// Seafarer number / nationality are two conditional fragments in the
// original — placed here as their own blocks rather than forced into one,
// so either can be deleted independently if a design does not need it.
blocks.push(
text('seafarerNumber', undefined, 15, 50, 34, { align: 'center', fontSize: 9.5, color: '#5b6b7f' }),
text('holderNationality', undefined, 51, 50, 34, { align: 'center', fontSize: 9.5, color: '#5b6b7f' }),
);
blocks.push(
text(
null,
'has met the STCW requirements and is entitled to serve in the capacity stated below, in accordance with the regulations of the Ethiopian Maritime Authority.',
15,
54,
70,
{ align: 'center', fontSize: 10, color: '#34495e' },
),
);
// Facts — the original table becomes six label/value pairs, two columns.
// Labels are literal text; the label column's own header cell ("Certificate
// No.", "Department", …) has no variable, so those are plain `text` blocks.
const rowY = [64, 68, 72];
const col = [
{ labelX: 16, valueX: 30 },
{ labelX: 55, valueX: 68 },
];
const facts: { label: string; variable: string; row: number; col: number }[] = [
{ label: 'Certificate No.', variable: 'certificateNumber', row: 0, col: 0 },
{ label: 'Department', variable: 'form.certificate.department', row: 0, col: 1 },
// Approximates the original's rank/proficiency fallback chain with a
// single field — see the module doc for what this loses on a CoP.
{ label: 'Rank / Capacity', variable: 'form.certificate.rank', row: 1, col: 0 },
{ label: 'Date of Issue', variable: 'issueDate', row: 1, col: 1 },
{ label: 'Application No.', variable: 'applicationNumber', row: 2, col: 0 },
{ label: 'Valid Until', variable: 'expiryDate', row: 2, col: 1 },
];
for (const fact of facts) {
const { labelX, valueX } = col[fact.col];
const y = rowY[fact.row];
blocks.push(
text(null, fact.label, labelX, y, 13, { fontSize: 9.5, color: '#5b6b7f' }),
text(fact.variable, undefined, valueX, y, 20, { fontSize: 9.5, fontWeight: 'bold' }),
);
}
// Limitations, conditional in the source — kept as its own row so deleting
// the block is how a design without a Limitations line removes it.
blocks.push(
text(null, 'Limitations', 16, 78, 13, { fontSize: 9.5, color: '#5b6b7f' }),
text('rankLimitation', undefined, 30, 78, 58, { fontSize: 9.5, fontWeight: 'bold' }),
);
// Footer: seal, signature block, QR — matching the original's left-16mm,
// right-16mm, bottom-12mm footer band.
blocks.push(
image('sealImage', 18, yPct(210 - 12 - 28), 12),
// The horizontal "signed above this line" rule under the signature image
// has no block equivalent (the canvas draws no shape primitives) and is
// dropped rather than faked with an empty text block.
image('signatureImage', 42, yPct(210 - 12 - 20), 16),
text('approverName', undefined, 38, yPct(210 - 12), 24, { align: 'center', fontSize: 9, fontWeight: 'bold' }),
text('approverRole', undefined, 38, yPct(210 - 12 + 3.5), 24, { align: 'center', fontSize: 8, color: '#5b6b7f' }),
image('qrImage', xPct(297 - 16 - 26), yPct(210 - 12 - 26), 9),
text(null, 'Scan to verify', xPct(297 - 16 - 34), yPct(210 - 12), 12, {
align: 'center',
fontSize: 7,
color: '#5b6b7f',
}),
);
return blocks;
}

View File

@@ -158,6 +158,19 @@ export function useTemplateDraft(templates: LicenseTemplate[]) {
);
}, []);
/**
* Replaces the draft's blocks wholesale — how a one-time HTML-to-canvas
* conversion hands off its result. Distinct from `addBlock`/`updateBlock`,
* which grow or edit the canvas one block at a time; this is the only
* caller that sets the whole array in one step.
*/
const replaceAllBlocks = useCallback((blocks: TemplateFieldPlacement[]) => {
setPlacements(blocks);
setSelectedBlockId(null);
setCurrentPage(0);
setExtraPages(0);
}, []);
const deleteBlock = useCallback((id: string) => {
setPlacements((prev) => prev.filter((block) => block.id !== id));
setSelectedBlockId((current) => (current === id ? null : current));
@@ -213,6 +226,7 @@ export function useTemplateDraft(templates: LicenseTemplate[]) {
addBlock,
updateBlock,
deleteBlock,
replaceAllBlocks,
editorRef,
isPublished,
dirty,

View File

@@ -17,6 +17,7 @@ import {
IconLayoutBoard,
IconLock,
IconPlus,
IconWand,
} from '@tabler/icons-react';
import { useTranslation } from 'react-i18next';
import {
@@ -44,6 +45,7 @@ import { TemplateVariableList } from '../components/TemplateVariableList';
import { TemplateVersionList } from '../components/TemplateVersionList';
import { pageOptionsFor } from '../config/designer';
import { compileLayoutToHbs } from '../config/layout-compiler';
import { convertStcwTemplateToBlocks, looksLikeStcwTemplate } from '../config/stcw-canvas-conversion';
import { useDesignerActions } from '../hooks/useDesignerActions';
import { useTemplateDraft } from '../hooks/useTemplateDraft';
import { useTemplatePreview } from '../hooks/useTemplatePreview';
@@ -100,6 +102,7 @@ export function CertificateDesignerPage() {
const [newOpen, setNewOpen] = useState(false);
const [newName, setNewName] = useState('');
const [deleteOpen, setDeleteOpen] = useState(false);
const [convertOpen, setConvertOpen] = useState(false);
const [mode, setMode] = useState<'canvas' | 'source'>('canvas');
const selectedType = licenseTypes?.items?.find((type) => type.id === typeId);
@@ -259,6 +262,16 @@ export function CertificateDesignerPage() {
<Tabs.Panel value="canvas" pt="sm">
<Stack gap="sm">
{!draft.usesCanvas && (
<Paper withBorder p="xs" bg="var(--mantine-color-yellow-light)">
<Text size="xs">
{t(
'designer.sourceOwnsCanvas',
'This design is hand-written HTML (see the HTML source tab) — the canvas below is empty because it has no blocks, not because the design is empty. Adding a block here starts a canvas layout that will replace the hand-written HTML on save.',
)}
</Text>
</Paper>
)}
<TextInput
label={t('designer.name', 'Version name')}
value={draft.name}
@@ -303,6 +316,26 @@ export function CertificateDesignerPage() {
</Text>
</Paper>
)}
{!draft.usesCanvas && looksLikeStcwTemplate(draft.source) && !editingLocked && (
<Paper withBorder p="xs" mb="sm">
<Group justify="space-between" wrap="nowrap">
<Text size="xs" c="dimmed">
{t(
'designer.convertHint',
'Recognised as the STCW certificate layout — it can be converted to visual-editor blocks to keep editing it there.',
)}
</Text>
<Button
size="xs"
variant="light"
leftSection={<IconWand size={14} />}
onClick={() => setConvertOpen(true)}
>
{t('designer.convertToCanvas', 'Convert to canvas')}
</Button>
</Group>
</Paper>
)}
<TemplateEditor
name={draft.name}
onNameChange={draft.setName}
@@ -457,6 +490,65 @@ export function CertificateDesignerPage() {
</Stack>
</Modal>
<Modal
opened={convertOpen}
onClose={() => setConvertOpen(false)}
title={t('designer.convertToCanvas', 'Convert to canvas')}
size="md"
>
<Stack gap="md">
<Text size="sm">
{t(
'designer.convertExplain',
'This reproduces the certificates fields as visual-editor blocks so you can keep editing it there. It is an approximation, not an exact copy:',
)}
</Text>
<Stack gap={4} component="ul" style={{ margin: 0, paddingLeft: 20 }}>
<Text component="li" size="sm">
{t(
'designer.convertLoseBorder',
'The sheet border, outline and table row lines are dropped — the canvas has no border or table to draw them with.',
)}
</Text>
<Text component="li" size="sm">
{t(
'designer.convertLoseFallback',
'A Certificate of Proficiencys rank/proficiency field collapses to one block (Rank / Capacity) — if the applicant used a different field, that blocks variable needs changing by hand.',
)}
</Text>
<Text component="li" size="sm">
{t(
'designer.convertLoseTitle',
'The "Certificate of Competency / Proficiency" title becomes fixed text for whichever licence type you are converting from — it no longer switches automatically.',
)}
</Text>
</Stack>
<Text size="sm" fw={600}>
{t(
'designer.convertIrreversible',
'Once converted, the canvas becomes this drafts design — the hand-written HTML is replaced on save.',
)}
</Text>
<ModalFooter>
<Button variant="default" onClick={() => setConvertOpen(false)}>
{t('common.cancel', 'Cancel')}
</Button>
<Button
leftSection={<IconWand size={14} />}
onClick={() => {
draft.replaceAllBlocks(
convertStcwTemplateToBlocks(selectedType?.name?.en ?? 'Certificate of Competency'),
);
setConvertOpen(false);
setMode('canvas');
}}
>
{t('designer.convertToCanvas', 'Convert to canvas')}
</Button>
</ModalFooter>
</Stack>
</Modal>
<PdfPreviewModal
opened={Boolean(previewUrl)}
onClose={closePreview}