From 0c895c14a14b775999ed09052ee6dd97af53c6e9 Mon Sep 17 00:00:00 2001 From: nati Date: Tue, 8 Sep 2026 08:29:10 +0000 Subject: [PATCH] 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. --- .../components/ImportHtmlModal.tsx | 94 +++ .../components/TemplateEditor.tsx | 24 +- .../config/html-import.spec.ts | 435 ++++++++++++++ .../config/html-import.ts | 565 ++++++++++++++++++ .../config/stcw-canvas-conversion.ts | 222 ------- .../pages/CertificateDesignerPage.tsx | 112 +--- .../pages/CertificationPage/index.tsx | 12 +- .../components/OperationsFormContent.tsx | 48 +- apps/portal/src/app/i18n/locales/am.ts | 2 + apps/portal/src/app/i18n/locales/en.ts | 2 + .../lib/features/licensing/licensing.types.ts | 13 +- 11 files changed, 1218 insertions(+), 311 deletions(-) create mode 100644 apps/backoffice/src/app/features/certificate-designer/components/ImportHtmlModal.tsx create mode 100644 apps/backoffice/src/app/features/certificate-designer/config/html-import.spec.ts create mode 100644 apps/backoffice/src/app/features/certificate-designer/config/html-import.ts delete mode 100644 apps/backoffice/src/app/features/certificate-designer/config/stcw-canvas-conversion.ts diff --git a/apps/backoffice/src/app/features/certificate-designer/components/ImportHtmlModal.tsx b/apps/backoffice/src/app/features/certificate-designer/components/ImportHtmlModal.tsx new file mode 100644 index 000000000..35d2b44f7 --- /dev/null +++ b/apps/backoffice/src/app/features/certificate-designer/components/ImportHtmlModal.tsx @@ -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 ( + + + 0 ? 'teal' : 'yellow'} + icon={placements.length > 0 ? : } + > + + {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.', + )} + + + + {unmapped.length > 0 && ( + + + {t('designer.importUnmappedTitle', '{{count}} element(s) need manual placement:', { + count: unmapped.length, + })} + + + + + }> + {unmapped.map((w, i) => ( + + {w.element} — {w.reason} + + ))} + + + )} + + + {t( + 'designer.importOverwriteWarning', + 'Applying replaces every block currently on the canvas for this version.', + )} + + + + + + + + + ); +} diff --git a/apps/backoffice/src/app/features/certificate-designer/components/TemplateEditor.tsx b/apps/backoffice/src/app/features/certificate-designer/components/TemplateEditor.tsx index 36175a7b7..d57b58c48 100644 --- a/apps/backoffice/src/app/features/certificate-designer/components/TemplateEditor.tsx +++ b/apps/backoffice/src/app/features/certificate-designer/components/TemplateEditor.tsx @@ -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; 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 && ( + + + + )} {isPublished && ( diff --git a/apps/backoffice/src/app/features/certificate-designer/config/html-import.spec.ts b/apps/backoffice/src/app/features/certificate-designer/config/html-import.spec.ts new file mode 100644 index 000000000..bc8eccb89 --- /dev/null +++ b/apps/backoffice/src/app/features/certificate-designer/config/html-import.spec.ts @@ -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> = {}, +): 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 { + 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 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 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