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 && (
+
+ }
+ disabled={disabled || !source.trim()}
+ onClick={onImportToCanvas}
+ >
+ {t('designer.importToCanvas', 'Import to canvas')}
+
+
+ )}
{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