feat: add HTML import functionality for certificate designer

- Implemented `html-import.ts` to parse Handlebars/HTML certificates into canvas blocks.
- Introduced `htmlToPlacements` function to convert parsed HTML into structured placements.
- Added inline style parsing and unit conversion for various CSS lengths.
- Created test suite in `html-import.spec.ts` to validate the import logic and edge cases.
- Ensured compatibility with existing template field placements and background image handling.
This commit is contained in:
nati
2026-09-08 08:29:10 +00:00
parent 41067fb07f
commit 0c895c14a1
11 changed files with 1218 additions and 311 deletions

View File

@@ -0,0 +1,94 @@
import { Alert, Button, Code, List, Modal, Stack, Text, ThemeIcon } from '@mantine/core';
import { IconAlertTriangle, IconCircleCheck } from '@tabler/icons-react';
import { useTranslation } from 'react-i18next';
import type { ImportResult } from '../config/html-import';
interface Props {
opened: boolean;
onClose: () => void;
result: ImportResult | null;
/** Replaces the draft's blocks/background with the imported ones. */
onApply: () => void;
}
/**
* The result of "Import to canvas" — how many blocks landed, and an itemized
* list of what could not be placed and why.
*
* Shown before the import is applied (not after) so a design that resolves
* to nothing (a flex/table-heavy hand-written template — see
* `html-import.ts`'s own doc comment) is not silently swapped in as an empty
* canvas; the author decides whether the partial result is still worth
* having.
*/
export function ImportHtmlModal({ opened, onClose, result, onApply }: Props) {
const { t } = useTranslation();
if (!result) return null;
const { placements, unmapped, backgroundUrl } = result;
return (
<Modal opened={opened} onClose={onClose} title={t('designer.importTitle', 'Import to canvas')} size="lg">
<Stack gap="md">
<Alert
variant="light"
color={placements.length > 0 ? 'teal' : 'yellow'}
icon={placements.length > 0 ? <IconCircleCheck size={18} /> : <IconAlertTriangle size={18} />}
>
<Text size="sm">
{placements.length > 0
? t(
'designer.importSummary',
'{{count}} element(s) placed on the canvas{{bg}}.',
{
count: placements.length,
bg: backgroundUrl ? t('designer.importAndBackground', ', plus the background image') : '',
},
)
: t(
'designer.importNothingPlaced',
'Nothing could be placed on the canvas. This design uses layout the canvas cannot represent — see below.',
)}
</Text>
</Alert>
{unmapped.length > 0 && (
<Stack gap={4}>
<Text size="sm" fw={600}>
{t('designer.importUnmappedTitle', '{{count}} element(s) need manual placement:', {
count: unmapped.length,
})}
</Text>
<List size="sm" spacing={4} icon={
<ThemeIcon color="yellow" size={16} radius="xl" variant="light">
<IconAlertTriangle size={10} />
</ThemeIcon>
}>
{unmapped.map((w, i) => (
<List.Item key={i}>
<Code fz={11}>{w.element}</Code> {w.reason}
</List.Item>
))}
</List>
</Stack>
)}
<Text size="xs" c="dimmed">
{t(
'designer.importOverwriteWarning',
'Applying replaces every block currently on the canvas for this version.',
)}
</Text>
<Button.Group>
<Button variant="default" onClick={onClose} style={{ flex: 1 }}>
{t('common.cancel', 'Cancel')}
</Button>
<Button onClick={onApply} disabled={placements.length === 0 && !backgroundUrl} style={{ flex: 1 }}>
{t('designer.importApply', 'Apply to canvas')}
</Button>
</Button.Group>
</Stack>
</Modal>
);
}

View File

@@ -1,5 +1,6 @@
import type { RefObject } from 'react';
import { Group, Paper, Switch, Text, TextInput, Textarea } from '@mantine/core';
import { Button, Group, Paper, Switch, Text, TextInput, Textarea, Tooltip } from '@mantine/core';
import { IconFileImport } from '@tabler/icons-react';
import { useTranslation } from 'react-i18next';
interface Props {
@@ -12,6 +13,8 @@ interface Props {
editorRef: RefObject<HTMLTextAreaElement | null>;
disabled: boolean;
isPublished: boolean;
/** Parses `source` and opens the import report — undefined hides the button entirely. */
onImportToCanvas?: () => void;
}
/** Name, orientation and the Handlebars source for the selected version. */
@@ -25,6 +28,7 @@ export function TemplateEditor({
editorRef,
disabled,
isPublished,
onImportToCanvas,
}: Props) {
const { t } = useTranslation();
@@ -44,6 +48,24 @@ export function TemplateEditor({
onChange={(e) => onLandscapeChange(e.currentTarget.checked)}
disabled={disabled}
/>
{onImportToCanvas && (
<Tooltip
label={t(
'designer.importHint',
'Reads this HTML and places whatever it can as canvas blocks — a report lists what could not be placed.',
)}
>
<Button
variant="default"
size="sm"
leftSection={<IconFileImport size={15} />}
disabled={disabled || !source.trim()}
onClick={onImportToCanvas}
>
{t('designer.importToCanvas', 'Import to canvas')}
</Button>
</Tooltip>
)}
</Group>
{isPublished && (

View File

@@ -0,0 +1,435 @@
import { describe, expect, it } from 'vitest';
import { htmlToPlacements, parseInlineStyle, type ImportedElement } from './html-import';
import { compileLayoutToHbs } from './layout-compiler';
import type { TemplateFieldPlacement } from '@ema-platform/api';
/** Builds a fixture element without having to repeat every empty field. */
function el(
tag: string,
opts: Partial<Pick<ImportedElement, 'style' | 'attrs' | 'text' | 'children'>> = {},
): ImportedElement {
return {
tag,
style: opts.style ?? {},
attrs: opts.attrs ?? {},
text: opts.text ?? '',
children: opts.children ?? [],
};
}
/** style="..." attribute value → the parsed-declaration object the walker expects. */
function styled(css: string): Record<string, string> {
return parseInlineStyle(css);
}
describe('parseInlineStyle', () => {
it('splits declarations and lowercases property names', () => {
expect(parseInlineStyle('Position: absolute; Left:10%; width : 30% ')).toEqual({
position: 'absolute',
left: '10%',
width: '30%',
});
});
it('ignores malformed declarations and empty input', () => {
expect(parseInlineStyle('not-a-declaration')).toEqual({});
expect(parseInlineStyle(undefined)).toEqual({});
expect(parseInlineStyle(null)).toEqual({});
});
});
describe('htmlToPlacements — text blocks', () => {
it('places an absolutely-positioned literal text leaf', () => {
const root = el('body', {
children: [
el('div', { style: styled('position:absolute;left:10%;top:20%;width:30%;'), text: 'Hello' }),
],
});
const { placements, unmapped } = htmlToPlacements(root);
expect(unmapped).toEqual([]);
expect(placements).toHaveLength(1);
expect(placements[0]).toMatchObject({
variable: null,
text: 'Hello',
type: 'text',
xPct: 10,
yPct: 20,
widthPct: 30,
});
});
it('recognises a {{variable}} leaf as a variable block, not literal text', () => {
const root = el('body', {
children: [
el('div', { style: styled('position:absolute;left:0%;top:0%;width:40%;'), text: '{{holderName}}' }),
],
});
const { placements } = htmlToPlacements(root);
expect(placements[0]).toMatchObject({ variable: 'holderName', text: undefined });
});
it('reads font styling off the element', () => {
const root = el('body', {
children: [
el('div', {
style: styled(
'position:absolute;left:5%;top:5%;width:50%;font-size:18px;font-weight:bold;font-style:italic;text-align:center;color:#0b3d6b;',
),
text: 'Styled',
}),
],
});
const { placements } = htmlToPlacements(root);
expect(placements[0]).toMatchObject({
fontSize: 18,
fontWeight: 'bold',
fontStyle: 'italic',
align: 'center',
color: '#0b3d6b',
});
});
it('converts pt font sizes and numeric font-weight to the canvas convention', () => {
const root = el('body', {
children: [
el('div', {
style: styled('position:absolute;left:0%;top:0%;width:10%;font-size:12pt;font-weight:700;'),
text: 'x',
}),
],
});
const { placements } = htmlToPlacements(root);
expect(placements[0].fontSize).toBe(16); // 12pt * 96/72
expect(placements[0].fontWeight).toBe('bold');
});
});
describe('htmlToPlacements — images', () => {
it('places an absolutely-positioned <img> as an image block', () => {
const root = el('body', {
children: [
el('img', {
style: styled('position:absolute;left:70%;top:5%;width:20%;'),
attrs: { src: '{{{sealImage}}}' },
}),
],
});
const { placements } = htmlToPlacements(root);
expect(placements[0]).toMatchObject({
type: 'image',
variable: 'sealImage',
xPct: 70,
yPct: 5,
widthPct: 20,
});
});
it('treats a full-bleed <img> as the background rather than a block', () => {
const root = el('body', {
children: [
el('img', {
style: styled('position:absolute;inset:0;width:100%;height:100%;'),
attrs: { src: 'https://example.org/bg.png' },
}),
el('div', { style: styled('position:absolute;left:0%;top:0%;width:10%;'), text: 'Hi' }),
],
});
const { placements, backgroundUrl } = htmlToPlacements(root);
expect(backgroundUrl).toBe('https://example.org/bg.png');
expect(placements).toHaveLength(1); // only the text block, not the background image
});
it('recognises class="ema-background" with no inline position at all', () => {
// Exactly what compileLayoutToHbs itself emits: the image carries no
// inline style — position:absolute;inset:0 lives only in the compiled
// document's <style> block, under the .ema-background selector.
const root = el('body', {
children: [
el('img', { attrs: { class: 'ema-background', src: 'https://example.org/bg.png' } }),
el('div', { style: styled('position:absolute;left:0%;top:0%;width:10%;'), text: 'Hi' }),
],
});
const { placements, backgroundUrl, unmapped } = htmlToPlacements(root);
expect(backgroundUrl).toBe('https://example.org/bg.png');
expect(placements).toHaveLength(1);
expect(unmapped).toEqual([]);
});
it('extracts the mustache variable out of a background image src', () => {
const root = el('body', {
children: [
el('img', {
style: styled('position:absolute;left:0;top:0;width:100%;height:100%;'),
attrs: { src: '{{{backgroundUrl}}}' },
}),
],
});
const { backgroundUrl } = htmlToPlacements(root);
expect(backgroundUrl).toBe('backgroundUrl');
});
it('reports a non-positioned image as unmapped rather than guessing a spot', () => {
const root = el('body', {
children: [el('img', { attrs: { src: 'logo.png' } })],
});
const { placements, unmapped } = htmlToPlacements(root);
expect(placements).toEqual([]);
expect(unmapped).toHaveLength(1);
expect(unmapped[0].reason).toMatch(/not absolutely positioned/);
});
});
describe('htmlToPlacements — a positioned wrapper around one child', () => {
it('imports a single meaningful child, inheriting the wrapper box', () => {
const root = el('body', {
children: [
el('div', {
style: styled('position:absolute;left:15%;top:15%;width:25%;'),
children: [el('span', { text: '{{approverName}}' })],
}),
],
});
const { placements, unmapped } = htmlToPlacements(root);
expect(unmapped).toEqual([]);
expect(placements).toHaveLength(1);
expect(placements[0]).toMatchObject({ variable: 'approverName', xPct: 15, yPct: 15, widthPct: 25 });
});
it('reports a positioned wrapper with multiple children as unmapped', () => {
const root = el('body', {
children: [
el('div', {
style: styled('position:absolute;left:0%;top:0%;width:50%;'),
children: [el('span', { text: 'A' }), el('span', { text: 'B' })],
}),
],
});
const { placements, unmapped } = htmlToPlacements(root);
expect(placements).toEqual([]);
expect(unmapped).toHaveLength(1);
expect(unmapped[0].reason).toMatch(/more than one piece of content/);
});
});
describe('htmlToPlacements — things it deliberately does not import', () => {
it('flags a table', () => {
const root = el('body', { children: [el('table', { attrs: { class: 'facts' } })] });
const { unmapped } = htmlToPlacements(root);
expect(unmapped).toHaveLength(1);
expect(unmapped[0].element).toBe('<table class="facts">');
expect(unmapped[0].reason).toMatch(/Tables/);
});
it('flags a flex container', () => {
const root = el('body', { children: [el('div', { style: styled('display:flex;') })] });
const { unmapped } = htmlToPlacements(root);
expect(unmapped[0].reason).toMatch(/Flex\/grid/);
});
it('flags a grid container', () => {
const root = el('body', { children: [el('div', { style: styled('display:grid;') })] });
expect(htmlToPlacements(root).unmapped[0].reason).toMatch(/Flex\/grid/);
});
it('flags a block helper ({{#if}}) rather than treating it as literal text', () => {
const root = el('body', {
children: [el('div', { text: '{{#if rankLimitation}}{{rankLimitation}}{{/if}}' })],
});
const { placements, unmapped } = htmlToPlacements(root);
expect(placements).toEqual([]);
expect(unmapped[0].reason).toMatch(/conditional\/loop/);
});
it('does not flag a plain structural wrapper with no content of its own', () => {
const root = el('body', {
children: [
el('div', {
attrs: { class: 'wrapper' },
children: [el('div', { style: styled('position:absolute;left:0%;top:0%;width:10%;'), text: 'x' })],
}),
],
});
const { unmapped } = htmlToPlacements(root);
expect(unmapped).toEqual([]);
});
it('imports only the first .ema-page and flags the rest', () => {
const root = el('body', {
children: [
el('div', { attrs: { class: 'ema-page' }, children: [el('div', { style: styled('position:absolute;left:0%;top:0%;width:10%;'), text: 'p1' })] }),
el('div', { attrs: { class: 'ema-page' }, children: [el('div', { style: styled('position:absolute;left:0%;top:0%;width:10%;'), text: 'p2' })] }),
el('div', { attrs: { class: 'ema-page' }, children: [el('div', { style: styled('position:absolute;left:0%;top:0%;width:10%;'), text: 'p3' })] }),
],
});
const { placements, unmapped } = htmlToPlacements(root);
expect(placements).toHaveLength(1);
expect(placements[0].text).toBe('p1');
// One warning per later page, not one for the whole rest of the document.
expect(unmapped).toHaveLength(2);
expect(unmapped.every((w) => w.reason.match(/later page/))).toBe(true);
});
it('imports everything when the source has no .ema-page wrapper at all', () => {
const root = el('body', {
children: [el('div', { style: styled('position:absolute;left:0%;top:0%;width:10%;'), text: 'plain' })],
});
const { placements, unmapped } = htmlToPlacements(root);
expect(placements).toHaveLength(1);
expect(unmapped).toEqual([]);
});
});
describe('htmlToPlacements — unit conversion', () => {
it('converts mm against an explicit page size', () => {
const root = el('body', {
children: [el('div', { style: styled('position:absolute;left:21mm;top:29.7mm;width:105mm;'), text: 'x' })],
});
const { placements } = htmlToPlacements(root, { pageWidthMm: 210, pageHeightMm: 297 });
expect(placements[0].xPct).toBeCloseTo(10, 5);
expect(placements[0].yPct).toBeCloseTo(10, 5);
expect(placements[0].widthPct).toBeCloseTo(50, 5);
});
it('resolves width from right when width is absent', () => {
const root = el('body', {
children: [el('div', { style: styled('position:absolute;left:10%;top:0%;right:20%;'), text: 'x' })],
});
const { placements } = htmlToPlacements(root);
expect(placements[0].widthPct).toBe(70); // 100 - 10 - 20
});
it('treats an unresolvable length (calc/auto) as unmapped, not a guess', () => {
const root = el('body', {
children: [el('div', { style: styled('position:absolute;left:calc(50% - 10px);top:0%;width:10%;'), text: 'x' })],
});
const { placements, unmapped } = htmlToPlacements(root);
expect(placements).toEqual([]);
expect(unmapped[0].reason).toMatch(/plain length/);
});
});
describe('round trip — importing EMA\'s own compiled canvas output', () => {
it('reproduces the same placements a canvas design was compiled from', () => {
const original: TemplateFieldPlacement[] = [
{ id: 'a', variable: 'holderName', xPct: 10, yPct: 10, widthPct: 40, fontSize: 16, fontWeight: 'bold', fontStyle: 'normal', align: 'left', color: '#111111' },
{ id: 'b', variable: null, text: 'Certificate of Competency', xPct: 10, yPct: 30, widthPct: 60, fontSize: 20, fontWeight: 'bold', fontStyle: 'normal', align: 'center', color: '#0b3d6b' },
{ id: 'c', variable: 'sealImage', type: 'image', xPct: 70, yPct: 80, widthPct: 15 },
];
const hbs = compileLayoutToHbs({
backgroundUrl: 'https://assets.example.org/bg.png',
logoUrl: '',
logoPlacement: {},
fieldPlacements: original,
});
const root = parseFragment(hbs);
const { placements, backgroundUrl, unmapped } = htmlToPlacements(root);
expect(unmapped).toEqual([]);
expect(backgroundUrl).toBe('https://assets.example.org/bg.png');
expect(placements).toHaveLength(3);
const byVarOrText = (p: TemplateFieldPlacement) => p.variable ?? p.text;
for (const orig of original) {
const match = placements.find((p) => byVarOrText(p) === byVarOrText(orig));
expect(match, `expected a re-imported block for ${byVarOrText(orig)}`).toBeTruthy();
expect(match?.xPct).toBeCloseTo(orig.xPct, 5);
expect(match?.yPct).toBeCloseTo(orig.yPct, 5);
expect(match?.widthPct).toBeCloseTo(orig.widthPct, 5);
if (orig.type === 'image') {
expect(match?.type).toBe('image');
} else {
expect(match?.fontSize).toBe(orig.fontSize);
expect(match?.fontWeight).toBe(orig.fontWeight);
expect(match?.align).toBe(orig.align);
expect(match?.color).toBe(orig.color);
}
}
});
});
/**
* A tiny, dependency-free HTML fragment parser — test-only. This app's test
* environment is plain Node with no DOM implementation (see vite.config.mts:
* "no jsdom environment... is needed"), so the round-trip test above cannot
* use a real `DOMParser`. It only ever needs to parse `compileLayoutToHbs`'s
* own well-formed output (self-closing `<img/>`, no comments, `<style>`
* treated as opaque text), not arbitrary HTML — the production import path
* (`importHtmlSource`) uses the browser's real `DOMParser` instead.
*/
function parseFragment(html: string): ImportedElement {
const VOID_TAGS = new Set(['img', 'br', 'hr', 'meta', 'link', 'input']);
const OPAQUE_TAGS = new Set(['style', 'script']);
let i = 0;
function parseAttrs(tagSrc: string): Record<string, string> {
const attrs: Record<string, string> = {};
const re = /([a-zA-Z-]+)\s*=\s*"([^"]*)"/g;
let m: RegExpExecArray | null;
while ((m = re.exec(tagSrc))) attrs[m[1]] = m[2];
return attrs;
}
/**
* Reads every node up to (and consuming) `</untilTag>`, or end of input for
* the synthetic document root (`untilTag === null`). Text runs become the
* *current* element's own text (the caller passes its own tag as
* `untilTag`); a nested element's opening tag starts a fresh recursive call
* so its own text is captured the same way, one level down.
*/
function parseUntil(untilTag: string | null): { text: string; children: ImportedElement[] } {
const children: ImportedElement[] = [];
let text = '';
while (i < html.length) {
if (html[i] !== '<') {
text += html[i];
i++;
continue;
}
if (html.startsWith('<!', i)) {
// Comment or doctype — skip to '>'.
i = html.indexOf('>', i) + 1;
continue;
}
if (html.startsWith('</', i)) {
const end = html.indexOf('>', i);
const closingTag = html.slice(i + 2, end).trim().toLowerCase();
i = end + 1;
if (closingTag === untilTag) return { text, children };
continue; // mismatched close (shouldn't happen in well-formed input) — ignore
}
const end = html.indexOf('>', i);
const tagSrc = html.slice(i + 1, end);
const selfClosing = tagSrc.trim().endsWith('/');
const tag = (tagSrc.match(/^([a-zA-Z0-9]+)/)?.[1] ?? '').toLowerCase();
const attrs = parseAttrs(tagSrc);
i = end + 1;
if (OPAQUE_TAGS.has(tag)) {
const close = `</${tag}>`;
const closeAt = html.indexOf(close, i);
i = closeAt === -1 ? html.length : closeAt + close.length;
continue;
}
if (selfClosing || VOID_TAGS.has(tag)) {
children.push({ tag, style: parseInlineStyle(attrs.style), attrs, text: '', children: [] });
continue;
}
const nested = parseUntil(tag);
children.push({ tag, style: parseInlineStyle(attrs.style), attrs, text: nested.text, children: nested.children });
}
return { text, children };
}
// `compileLayoutToHbs` always emits a full `<!doctype html><html>...`
// document — wrap the parsed top-level nodes in a synthetic root so the
// caller gets one element back, same shape `document.documentElement`
// would have been.
const { children } = parseUntil(null);
return { tag: 'root', style: {}, attrs: {}, text: '', children };
}

View File

@@ -0,0 +1,565 @@
import type { TemplateFieldPlacement } from '@ema-platform/api';
/**
* Imports a hand-authored Handlebars/HTML certificate into canvas blocks.
*
* This is necessarily best-effort. The canvas's own model (`TemplateFieldPlacement`)
* is a flat list of absolutely-positioned boxes — see `layout-compiler.ts`, the
* only thing that has ever produced one. Arbitrary HTML has no such
* constraint: a `<table>`, a flex/grid layout, a `{{#if}}` branch, or a
* relatively-flowed paragraph has no single `left/top/width` that means the
* same thing once lifted out of its surrounding markup. Rather than silently
* dropping those, every element the importer cannot place as a clean box is
* reported back so the author knows to finish it by hand (in the HTML source
* tab, or by rebuilding it as a block on the canvas).
*
* What DOES import cleanly: exactly the shape `compileLayoutToHbs` itself
* emits — an element with inline `position:absolute; left; top; width` (in
* %, mm, in, or px, resolved against the declared page size) carrying either
* a text/`{{variable}}` node or an `<img>`. Every bundled certificate design
* in this codebase is authored that way for exactly this reason — importing
* one and re-editing it on the canvas is a fully lossless round trip.
*/
// ---------------------------------------------------------------------------
// A minimal, DOM-library-agnostic element shape.
//
// The real adapter (`domElementToImported`, at the bottom) wraps a browser
// `Element`; tests build this shape directly, so the extraction logic itself
// needs no DOM implementation and runs the same in Node as in the browser.
// ---------------------------------------------------------------------------
export interface ImportedElement {
/** Lowercase tag name, e.g. "div", "img". */
tag: string;
/** Inline `style="..."` attribute, already split into declarations. */
style: Record<string, string>;
attrs: Record<string, string>;
/**
* This element's own text, excluding any child elements' text — i.e. what
* a DOM adapter would build from the element's direct Text node children
* only, not `element.textContent` (which would also pull in every
* descendant's text and double-count it once children are walked too).
*/
text: string;
children: ImportedElement[];
}
export interface ImportWarning {
/** Where the problem is, for the report: a short tag/class/id descriptor. */
element: string;
reason: string;
}
export interface ImportResult {
placements: TemplateFieldPlacement[];
/** First plain `<img>` covering (close to) the whole page — see `isBackgroundImage`. */
backgroundUrl: string | null;
unmapped: ImportWarning[];
}
export interface ImportOptions {
/** The page box these percentages are resolved against. Defaults to A4 portrait mm. */
pageWidthMm?: number;
pageHeightMm?: number;
}
const DEFAULT_PAGE_WIDTH_MM = 210;
const DEFAULT_PAGE_HEIGHT_MM = 297;
/** Structural tags that never become a block themselves — only their content does. */
const CONTAINER_TAGS = new Set(['html', 'body', 'head', 'style', 'script']);
/** Constructs a stable-enough id; collisions are harmless (React key + selection only). */
function blockId(index: number): string {
return `import_${index}_${Math.random().toString(36).slice(2, 8)}`;
}
// ---------------------------------------------------------------------------
// Unit conversion — every length the importer accepts, resolved to a percent
// of the declared page box. `%` values pass through unchanged (already what
// the canvas stores); everything else is a physical or pixel length assumed
// at 96 CSS px per inch, the same assumption a browser makes.
// ---------------------------------------------------------------------------
const PX_PER_IN = 96;
const MM_PER_IN = 25.4;
function toMm(value: number, unit: string): number | null {
switch (unit) {
case 'mm':
return value;
case 'cm':
return value * 10;
case 'in':
return value * MM_PER_IN;
case 'px':
return (value / PX_PER_IN) * MM_PER_IN;
case 'pt':
return ((value / 72) * MM_PER_IN);
default:
return null;
}
}
/**
* Resolves a CSS length against one page dimension, as a percent of it.
* `null` for anything not expressible as a plain length (a %, unitless
* number, `calc()`, `auto`, a CSS variable) — the caller decides what that
* means for the element carrying it.
*/
function lengthToPct(raw: string | undefined, pageDimensionMm: number): number | null {
if (!raw) return null;
const trimmed = raw.trim();
const pctMatch = trimmed.match(/^(-?[\d.]+)%$/);
if (pctMatch) return Number(pctMatch[1]);
const lengthMatch = trimmed.match(/^(-?[\d.]+)(mm|cm|in|px|pt)$/);
if (!lengthMatch) return null;
const mm = toMm(Number(lengthMatch[1]), lengthMatch[2]);
if (mm === null || pageDimensionMm <= 0) return null;
return (mm / pageDimensionMm) * 100;
}
// ---------------------------------------------------------------------------
// Inline style parsing — a plain `key:value;key:value` splitter. No CSS
// library needed: this only ever sees a `style="..."` attribute value, never
// a full stylesheet (selectors, media queries, `<style>` blocks are out of
// scope for the same reason a `<table>` is — see the module doc).
// ---------------------------------------------------------------------------
export function parseInlineStyle(styleAttr: string | null | undefined): Record<string, string> {
const out: Record<string, string> = {};
if (!styleAttr) return out;
for (const decl of styleAttr.split(';')) {
const colon = decl.indexOf(':');
if (colon === -1) continue;
const key = decl.slice(0, colon).trim().toLowerCase();
const value = decl.slice(colon + 1).trim();
if (key && value) out[key] = value;
}
return out;
}
// ---------------------------------------------------------------------------
// Mustache extraction from a text node — `{{{var}}}` (unescaped, the image
// convention) and `{{var}}` (escaped text/variable). A block helper opening
// or closing tag (`{{#if x}}`, `{{/if}}`, `{{else}}`) is deliberately not
// matched here: those don't name a single value to place, so they surface as
// literal, unmapped text instead of being silently swallowed.
// ---------------------------------------------------------------------------
const TRIPLE_MUSTACHE = /^\{\{\{\s*([\w.]+)\s*\}\}\}$/;
const DOUBLE_MUSTACHE = /^\{\{\s*([\w.]+)\s*\}\}$/;
const BLOCK_HELPER = /\{\{[#/]|\{\{\s*else\b/;
function extractVariable(text: string): { variable: string; raw: boolean } | null {
const trimmed = text.trim();
const triple = trimmed.match(TRIPLE_MUSTACHE);
if (triple) return { variable: triple[1], raw: true };
const double = trimmed.match(DOUBLE_MUSTACHE);
if (double) return { variable: double[1], raw: false };
return null;
}
// ---------------------------------------------------------------------------
// Geometry — is this element itself "absolutely placeable" as one box?
// ---------------------------------------------------------------------------
interface Box {
xPct: number;
yPct: number;
widthPct: number;
}
/**
* The box an element occupies, or null when it isn't expressed as a length
* the importer understands (a % or physical unit) against `left`/`top` and
* either `width` or `right`. `position` is checked by the caller — this only
* resolves the numbers once that gate has already passed.
*/
function boxFor(style: Record<string, string>, pageWidthMm: number, pageHeightMm: number): Box | null {
const left = lengthToPct(style.left, pageWidthMm);
const top = lengthToPct(style.top, pageHeightMm);
if (left === null || top === null) return null;
let width = lengthToPct(style.width, pageWidthMm);
if (width === null && style.right) {
const right = lengthToPct(style.right, pageWidthMm);
if (right !== null) width = 100 - left - right;
}
if (width === null) return null;
return { xPct: left, yPct: top, widthPct: width };
}
/** `font-size` resolved to px — the unit the canvas itself stores. */
function fontSizePx(style: Record<string, string>): number | undefined {
const raw = style['font-size'];
if (!raw) return undefined;
const match = raw.trim().match(/^([\d.]+)(px|pt)?$/);
if (!match) return undefined;
const n = Number(match[1]);
if (!Number.isFinite(n)) return undefined;
return match[2] === 'pt' ? Math.round(n * (96 / 72)) : Math.round(n);
}
function fontWeight(style: Record<string, string>): 'normal' | 'bold' | undefined {
const raw = style['font-weight']?.trim().toLowerCase();
if (!raw) return undefined;
if (raw === 'bold' || raw === 'bolder') return 'bold';
const n = Number(raw);
if (Number.isFinite(n)) return n >= 600 ? 'bold' : 'normal';
return 'normal';
}
function fontStyle(style: Record<string, string>): 'normal' | 'italic' | undefined {
const raw = style['font-style']?.trim().toLowerCase();
return raw === 'italic' || raw === 'oblique' ? 'italic' : undefined;
}
function textAlign(style: Record<string, string>): TemplateFieldPlacement['align'] | undefined {
const raw = style['text-align']?.trim().toLowerCase();
if (raw === 'left' || raw === 'center' || raw === 'right' || raw === 'justify') return raw;
return undefined;
}
/** A short, human-readable label for a warning — tag, id, first class, in that order of usefulness. */
function describe(el: ImportedElement): string {
if (el.attrs.id) return `<${el.tag} id="${el.attrs.id}">`;
const cls = el.attrs.class?.trim().split(/\s+/)[0];
if (cls) return `<${el.tag} class="${cls}">`;
return `<${el.tag}>`;
}
/**
* A plain `<img>` is treated as the page background — rather than a block —
* when it covers essentially the whole page: `inset:0` / `position:absolute`
* with no meaningful left/top/width of its own, or explicit 100%-ish
* width+height. This is the same role `compileLayoutToHbs` gives
* `.ema-background`, so importing one of EMA's own compiled designs finds it.
*
* `compileLayoutToHbs` itself gives that image no inline style at all — its
* `position:absolute; inset:0` comes entirely from the `.ema-background`
* class in the document's `<style>` block, which this importer does not
* (and, per the module doc, deliberately does not) resolve selectors
* against. `class="ema-background"` is checked first and directly for
* exactly that reason: it is the one CSS-class signal worth trusting, since
* it is the compiler's own marker for this element's role, not a generic
* stylesheet rule an importer would have to interpret.
*/
function isBackgroundImage(attrs: Record<string, string>, style: Record<string, string>): boolean {
if (attrs.class?.trim().split(/\s+/).includes('ema-background')) return true;
const position = style.position?.trim().toLowerCase();
if (position !== 'absolute' && position !== 'fixed') return false;
const inset = style.inset?.trim().replace(/\s+/g, ' ');
if (inset === '0' || inset === '0 0 0 0' || inset === '0px 0px 0px 0px') return true;
const width = style.width?.trim();
const height = style.height?.trim();
const noOffset = (!style.left || style.left.trim() === '0' || style.left.trim() === '0px')
&& (!style.top || style.top.trim() === '0' || style.top.trim() === '0px');
return noOffset && (width === '100%' || height === '100%');
}
// ---------------------------------------------------------------------------
// The walk.
// ---------------------------------------------------------------------------
/** True for an element that marks a page boundary — `compileLayoutToHbs`'s own `.ema-page` wrapper, one per sheet. */
function isPageWrapper(el: ImportedElement): boolean {
return el.attrs.class?.trim().split(/\s+/).includes('ema-page') ?? false;
}
/**
* Converts a parsed HTML/Handlebars document into canvas blocks.
*
* Only the first page's worth of content is imported — a booklet design
* (multiple `.ema-page` sections, as `compileLayoutToHbs` emits one per
* sheet) is reported as unmapped for every page after the first rather than
* guessed at, since nothing before the canvas existed carries a page
* boundary the importer can trust for markup that doesn't use that class.
*/
export function htmlToPlacements(root: ImportedElement, options: ImportOptions = {}): ImportResult {
const pageWidthMm = options.pageWidthMm ?? DEFAULT_PAGE_WIDTH_MM;
const pageHeightMm = options.pageHeightMm ?? DEFAULT_PAGE_HEIGHT_MM;
const placements: TemplateFieldPlacement[] = [];
const unmapped: ImportWarning[] = [];
let backgroundUrl: string | null = null;
let index = 0;
let sawFirstPageWrapper = false;
function walk(el: ImportedElement) {
if (CONTAINER_TAGS.has(el.tag)) {
for (const child of el.children) walk(child);
return;
}
// Every `.ema-page` after the first is a later sheet in a booklet — out
// of scope, see the doc comment above. A source with no `.ema-page` at
// all (every hand-written template except the canvas's own output) has
// nothing to gate here, and walks in full.
if (isPageWrapper(el)) {
if (sawFirstPageWrapper) {
unmapped.push({
element: describe(el),
reason: 'A later page in this booklet was not imported — the canvas imports one page at a time.',
});
return;
}
sawFirstPageWrapper = true;
for (const child of el.children) walk(child);
return;
}
const style = el.style;
const position = style.position?.trim().toLowerCase();
if (el.tag === 'img') {
const src = el.attrs.src ?? '';
if (isBackgroundImage(el.attrs, style)) {
if (!backgroundUrl) backgroundUrl = mustacheImageSource(src) ?? src;
else {
unmapped.push({ element: describe(el), reason: 'A second full-page image was skipped — only one background is supported.' });
}
return;
}
if (position !== 'absolute' && position !== 'fixed') {
unmapped.push({
element: describe(el),
reason: 'Image is not absolutely positioned (no position:absolute with left/top), so it has no fixed spot to place on the canvas.',
});
return;
}
const box = boxFor(style, pageWidthMm, pageHeightMm);
if (!box) {
unmapped.push({
element: describe(el),
reason: 'Image position/width is not a plain length (%, mm, in, px or pt) the canvas can place.',
});
return;
}
const variable = mustacheImageSource(src);
placements.push({
id: blockId(index++),
variable,
type: 'image',
xPct: box.xPct,
yPct: box.yPct,
widthPct: box.widthPct,
});
return;
}
if (position === 'absolute' || position === 'fixed') {
const box = boxFor(style, pageWidthMm, pageHeightMm);
if (!box) {
unmapped.push({
element: describe(el),
reason: 'Position/width is not a plain length (%, mm, in, px or pt) the canvas can place.',
});
// Still walk children — a positioned wrapper around plain
// (non-positioned) content is common hand-authored markup, and its
// children might still resolve their own positions.
for (const child of el.children) walk(child);
return;
}
// This element's own direct text, if any — a leaf like
// `<div style="position:absolute;...">{{holderName}}</div>`. `children`
// only ever holds real elements (text is folded into `.text` by the
// adapter/fixtures), so "no element children" is just an empty array.
const ownText = el.text.trim();
const hasElementChildren = el.children.length > 0;
if (ownText && !hasElementChildren) {
placeText(el, ownText, box);
return;
}
if (!ownText && !hasElementChildren) {
// An empty positioned box — nothing to place, and nothing to warn
// about either (e.g. a spacer div).
return;
}
// A positioned wrapper with element children (not a single text leaf):
// resolvable structure only if it holds exactly one meaningful child,
// which inherits the wrapper's box — a `<div style="position:absolute;...">
// <span>{{x}}</span></div>` pattern real templates sometimes use for a
// style hook. More than one child means real internal layout the
// canvas's flat model cannot represent.
const meaningfulChildren = el.children.filter(
(c) => c.text.trim() || c.children.length || c.tag === 'img',
);
if (meaningfulChildren.length === 1 && !ownText) {
const child = meaningfulChildren[0];
const childText = child.text.trim();
const childHasElementChildren = child.children.length > 0;
if (child.tag === 'img') {
const src = child.attrs.src ?? '';
placements.push({
id: blockId(index++),
variable: mustacheImageSource(src),
type: 'image',
xPct: box.xPct,
yPct: box.yPct,
widthPct: box.widthPct,
});
return;
}
if (childText && !childHasElementChildren) {
placeText({ ...child, style: { ...style, ...child.style } }, childText, box);
return;
}
}
unmapped.push({
element: describe(el),
reason: 'Positioned element holds more than one piece of content (a layout, not a single field) — split it into separate blocks by hand.',
});
return;
}
// Not itself positioned — a plain wrapper (e.g. `<body>`'s children
// before the canvas's own `.ema-page` div). Keep walking; only elements
// that are *never* going to resolve a box report a warning, so a
// structural `<div>` around everything doesn't itself get flagged.
if (el.tag === 'table') {
unmapped.push({
element: describe(el),
reason: 'Tables have no equivalent on the canvas (rows/columns cannot be expressed as fixed boxes) — recreate its cells as individual text blocks, or keep this design in the HTML source tab.',
});
return;
}
if (isFlexOrGrid(style)) {
unmapped.push({
element: describe(el),
reason: 'Flex/grid layout has no equivalent on the canvas — its children\'s positions depend on each other rather than the page, so they were not imported.',
});
return;
}
if (BLOCK_HELPER.test(el.text)) {
unmapped.push({
element: describe(el),
reason: 'Contains a conditional/loop ({{#if}}, {{#each}}, {{else}}) — the canvas can only place a fixed value, not branching logic.',
});
}
for (const child of el.children) walk(child);
}
function placeText(el: ImportedElement, ownText: string, box: Box) {
const mustache = extractVariable(ownText);
placements.push({
id: blockId(index++),
variable: mustache?.variable ?? null,
text: mustache ? undefined : ownText,
type: 'text',
xPct: box.xPct,
yPct: box.yPct,
widthPct: box.widthPct,
fontSize: fontSizePx(el.style),
fontWeight: fontWeight(el.style),
fontStyle: fontStyle(el.style),
align: textAlign(el.style),
color: el.style.color?.trim(),
});
}
walk(root);
return { placements, backgroundUrl, unmapped };
}
/** `{{{variable}}}` inside an `src` (the image convention) → the bare variable name, or null for a literal URL/data URI. */
function mustacheImageSource(src: string): string | null {
const trimmed = src.trim();
const triple = trimmed.match(TRIPLE_MUSTACHE);
if (triple) return triple[1];
const double = trimmed.match(DOUBLE_MUSTACHE);
if (double) return double[1];
return null;
}
function isFlexOrGrid(style: Record<string, string>): boolean {
const display = style.display?.trim().toLowerCase();
return display === 'flex' || display === 'inline-flex' || display === 'grid' || display === 'inline-grid';
}
// ---------------------------------------------------------------------------
// Browser adapter — the only part of this module that touches a real DOM.
// Kept tiny and separate so the extraction logic above stays unit-testable
// with plain object fixtures, matching this app's "pure helpers are tested,
// components/DOM code are not" convention (see vite.config.mts).
// ---------------------------------------------------------------------------
function domElementToImported(el: Element): ImportedElement {
const attrs: Record<string, string> = {};
for (const attr of Array.from(el.attributes)) attrs[attr.name] = attr.value;
let ownText = '';
const children: ImportedElement[] = [];
for (const node of Array.from(el.childNodes)) {
if (node.nodeType === 3 /* Node.TEXT_NODE */) {
ownText += node.textContent ?? '';
} else if (node.nodeType === 1 /* Node.ELEMENT_NODE */) {
children.push(domElementToImported(node as Element));
}
}
return {
tag: el.tagName.toLowerCase(),
style: parseInlineStyle(el.getAttribute('style')),
attrs,
text: ownText,
children,
};
}
/**
* Parses an HTML/Handlebars source string in the browser and extracts canvas
* blocks from it. `pageWidthMm`/`pageHeightMm` should match the design's own
* page size (the designer's current width/height fields) so `%`-based
* lengths — which every EMA-authored template already uses — resolve
* exactly, and physical units (mm/in/px) resolve consistently with it.
*/
export function importHtmlSource(html: string, options: ImportOptions = {}): ImportResult {
const doc = new DOMParser().parseFromString(html, 'text/html');
const root = domElementToImported(doc.documentElement);
return htmlToPlacements(root, options);
}
/**
* The designer's page-size fields (`landscape`, and optionally an explicit
* `width`/`height` CSS length, e.g. `"4.92in"` for the Seaman Book) resolved
* to millimetres, orientation applied. A4 portrait when no explicit size is
* set — the same default the designer itself falls back to (`pageOptionsFor`
* in `designer.ts`).
*/
export function pageDimensionsMm(
landscape: boolean,
pageWidth?: string,
pageHeight?: string,
): { pageWidthMm: number; pageHeightMm: number } {
const explicitW = toMmFromLength(pageWidth);
const explicitH = toMmFromLength(pageHeight);
let widthMm = explicitW ?? DEFAULT_PAGE_WIDTH_MM;
let heightMm = explicitH ?? DEFAULT_PAGE_HEIGHT_MM;
// Portrait is the box's natural reading; landscape swaps it, matching how
// `TemplateCanvas` and Puppeteer's own `landscape` page option both treat
// an explicit width/height pair (as a portrait box, rotated for print).
if (landscape && widthMm < heightMm) [widthMm, heightMm] = [heightMm, widthMm];
if (!landscape && widthMm > heightMm) [widthMm, heightMm] = [heightMm, widthMm];
return { pageWidthMm: widthMm, pageHeightMm: heightMm };
}
/** A CSS length string (e.g. "4.92in", "125mm") to millimetres, or null when absent/unparseable. */
function toMmFromLength(value: string | undefined): number | null {
if (!value?.trim()) return null;
const match = value.trim().match(/^([\d.]+)(mm|cm|in|px|pt)?$/);
if (!match) return null;
const n = Number(match[1]);
if (!Number.isFinite(n) || n <= 0) return null;
return toMm(n, match[2] ?? 'mm');
}

View File

@@ -1,222 +0,0 @@
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

@@ -17,7 +17,6 @@ import {
IconLayoutBoard,
IconLock,
IconPlus,
IconWand,
} from '@tabler/icons-react';
import { useTranslation } from 'react-i18next';
import {
@@ -36,6 +35,7 @@ import { EmptyState, ErrorState, ModalFooter, PageHeader, PdfPreviewModal } from
import { usePermissions, LICENSE_PERMISSIONS as PERMISSIONS } from '@ema-platform/auth';
import { BlockPropertiesPanel } from '../components/BlockPropertiesPanel';
import { DesignerToolbar } from '../components/DesignerToolbar';
import { ImportHtmlModal } from '../components/ImportHtmlModal';
import { NewVersionModal } from '../components/NewVersionModal';
import { TemplateActionBar } from '../components/TemplateActionBar';
import { TemplateBackgroundPanel } from '../components/TemplateBackgroundPanel';
@@ -45,7 +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 { importHtmlSource, pageDimensionsMm, type ImportResult } from '../config/html-import';
import { useDesignerActions } from '../hooks/useDesignerActions';
import { useTemplateDraft } from '../hooks/useTemplateDraft';
import { useTemplatePreview } from '../hooks/useTemplatePreview';
@@ -102,8 +102,8 @@ 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 [importResult, setImportResult] = useState<ImportResult | null>(null);
const selectedType = licenseTypes?.items?.find((type) => type.id === typeId);
@@ -143,6 +143,25 @@ export function CertificateDesignerPage() {
setNewOpen(true);
}
/** Parses the current HTML source and opens the report before touching the canvas. */
function importToCanvas() {
const { pageWidthMm, pageHeightMm } = pageDimensionsMm(
draft.landscape,
draft.pageWidth,
draft.pageHeight,
);
setImportResult(importHtmlSource(draft.source, { pageWidthMm, pageHeightMm }));
}
/** Replaces the canvas's blocks/background with what the import found. */
function applyImport() {
if (!importResult) return;
draft.setPlacements(importResult.placements);
if (importResult.backgroundUrl) draft.setBackgroundUrl(importResult.backgroundUrl);
setImportResult(null);
setMode('canvas');
}
const editingLocked = !canEdit || draft.isPublished;
return (
@@ -316,26 +335,6 @@ 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}
@@ -346,6 +345,7 @@ export function CertificateDesignerPage() {
editorRef={draft.editorRef}
disabled={editingLocked}
isPublished={draft.isPublished}
onImportToCanvas={importToCanvas}
/>
</Tabs.Panel>
</Tabs>
@@ -490,71 +490,19 @@ 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}
url={previewUrl ?? ''}
title={t('designer.preview', 'Preview')}
/>
<ImportHtmlModal
opened={Boolean(importResult)}
onClose={() => setImportResult(null)}
result={importResult}
onApply={applyImport}
/>
</Container>
);
}

View File

@@ -104,7 +104,17 @@ export function CertificationPage() {
const showError = (e: unknown) => notify.error(extractErrorMessage(e, t('certification.error')));
const { data, isFetching, isError, refetch } = useGetCertificationsQuery();
const { data: rankRes } = useGetRanksQuery();
const rankOptions = (rankRes?.items ?? []).map((r) => ({ value: r.key, label: localized(r.name) }));
// Every CoC rank exists three times: once per STCW tier (Above/Below,
// properly suffixed — see `rankTierSuffix` server-side) plus one untiered
// row kept only so a licence issued before the tier split still resolves
// to a catalogue entry. That legacy row's name carries no tier and reads
// as a plain duplicate ("Chief Mate" next to "Chief Mate — Above/Below"),
// so it is excluded here — a new certification should always be scoped to
// a real, disambiguated rung. CoP ranks have no tier at all and are
// untouched by this filter.
const rankOptions = (rankRes?.items ?? [])
.filter((r) => r.certificateCategory !== 'COC' || r.tier !== null)
.map((r) => ({ value: r.key, label: localized(r.name) }));
const { setPageIndex, pageSize, setPageSize, paginate } = useServerTable();
const [createCert, { isLoading: isCreating }] = useCreateCertificationMutation();
const [updateCert, { isLoading: isUpdating }] = useUpdateCertificationMutation();